mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-02 11:13:29 +00:00
[ENG-1007] Per-page onboarding forms (#1256)
* useMultiZodForm * fix imports * handle obStore.data undefined --------- Co-authored-by: Utku <74243531+utkubakir@users.noreply.github.com>
This commit is contained in:
parent
08ba4f917a
commit
795bb18d18
|
@ -3,28 +3,31 @@ import CreatingLibraryScreen from '~/screens/onboarding/CreatingLibrary';
|
|||
import GetStartedScreen from '~/screens/onboarding/GetStarted';
|
||||
import NewLibraryScreen from '~/screens/onboarding/NewLibrary';
|
||||
import PrivacyScreen from '~/screens/onboarding/Privacy';
|
||||
import { OnboardingContext, useContextValue } from '~/screens/onboarding/context';
|
||||
|
||||
const OnboardingStack = createStackNavigator<OnboardingStackParamList>();
|
||||
|
||||
export default function OnboardingNavigator() {
|
||||
return (
|
||||
<OnboardingStack.Navigator
|
||||
id="onboarding"
|
||||
initialRouteName="GetStarted"
|
||||
screenOptions={{ headerShown: false }}
|
||||
>
|
||||
<OnboardingStack.Screen name="GetStarted" component={GetStartedScreen} />
|
||||
<OnboardingStack.Screen name="NewLibrary" component={NewLibraryScreen} />
|
||||
<OnboardingStack.Screen name="Privacy" component={PrivacyScreen} />
|
||||
<OnboardingStack.Screen
|
||||
name="CreatingLibrary"
|
||||
component={CreatingLibraryScreen}
|
||||
options={{
|
||||
// Disable swipe back gesture
|
||||
gestureEnabled: false
|
||||
}}
|
||||
/>
|
||||
</OnboardingStack.Navigator>
|
||||
<OnboardingContext.Provider value={useContextValue()}>
|
||||
<OnboardingStack.Navigator
|
||||
id="onboarding"
|
||||
initialRouteName="GetStarted"
|
||||
screenOptions={{ headerShown: false }}
|
||||
>
|
||||
<OnboardingStack.Screen name="GetStarted" component={GetStartedScreen} />
|
||||
<OnboardingStack.Screen name="NewLibrary" component={NewLibraryScreen} />
|
||||
<OnboardingStack.Screen name="Privacy" component={PrivacyScreen} />
|
||||
<OnboardingStack.Screen
|
||||
name="CreatingLibrary"
|
||||
component={CreatingLibraryScreen}
|
||||
options={{
|
||||
// Disable swipe back gesture
|
||||
gestureEnabled: false
|
||||
}}
|
||||
/>
|
||||
</OnboardingStack.Navigator>
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,81 +1,15 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Text } from 'react-native';
|
||||
import {
|
||||
resetOnboardingStore,
|
||||
telemetryStore,
|
||||
useBridgeMutation,
|
||||
useDebugState,
|
||||
useOnboardingStore,
|
||||
usePlausibleEvent
|
||||
} from '@sd/client';
|
||||
import { PulseAnimation } from '~/components/animation/lottie';
|
||||
import { tw } from '~/lib/tailwind';
|
||||
import { OnboardingStackScreenProps } from '~/navigation/OnboardingNavigator';
|
||||
import { currentLibraryStore } from '~/utils/nav';
|
||||
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './GetStarted';
|
||||
|
||||
const CreatingLibraryScreen = ({ navigation }: OnboardingStackScreenProps<'CreatingLibrary'>) => {
|
||||
const [status, setStatus] = useState('Creating your library...');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const debugState = useDebugState();
|
||||
const obStore = useOnboardingStore();
|
||||
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const createLibrary = useBridgeMutation('library.create', {
|
||||
onSuccess: (lib) => {
|
||||
resetOnboardingStore();
|
||||
queryClient.setQueryData(['library.list'], (libraries: any) => [
|
||||
...(libraries || []),
|
||||
lib
|
||||
]);
|
||||
// Switch to the new library
|
||||
currentLibraryStore.id = lib.uuid;
|
||||
if (obStore.shareFullTelemetry) {
|
||||
submitPlausibleEvent({ event: { type: 'libraryCreate' } });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// TODO: Show toast
|
||||
resetOnboardingStore();
|
||||
navigation.navigate('GetStarted');
|
||||
}
|
||||
});
|
||||
|
||||
const created = useRef(false);
|
||||
|
||||
const create = async () => {
|
||||
telemetryStore.shareFullTelemetry = obStore.shareFullTelemetry;
|
||||
createLibrary.mutate({ name: obStore.newLibraryName });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (created.current == true) return;
|
||||
created.current = true;
|
||||
create();
|
||||
const timer = setTimeout(() => {
|
||||
setStatus('Almost done...');
|
||||
}, 2000);
|
||||
const timer2 = setTimeout(() => {
|
||||
if (debugState.enabled) {
|
||||
setStatus(`You're running in development, this will take longer...`);
|
||||
}
|
||||
}, 5000);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(timer2);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const CreatingLibraryScreen = () => {
|
||||
return (
|
||||
<OnboardingContainer>
|
||||
<Text style={tw`mb-4 text-5xl`}>🛠</Text>
|
||||
<OnboardingTitle>Creating your library</OnboardingTitle>
|
||||
<OnboardingDescription style={tw`mt-4`}>{status}</OnboardingDescription>
|
||||
<OnboardingDescription style={tw`mt-4`}>Creating your library...</OnboardingDescription>
|
||||
<PulseAnimation style={tw`mt-2 h-10`} speed={0.3} />
|
||||
</OnboardingContainer>
|
||||
);
|
||||
|
|
|
@ -1,32 +1,17 @@
|
|||
import { Database } from '@sd/assets/icons';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Alert, Image, Text, View } from 'react-native';
|
||||
import { getOnboardingStore, useOnboardingStore } from '@sd/client';
|
||||
import { Input } from '~/components/form/Input';
|
||||
import { Button } from '~/components/primitive/Button';
|
||||
import { useZodForm, z } from '~/hooks/useZodForm';
|
||||
import { tw } from '~/lib/tailwind';
|
||||
import { OnboardingStackScreenProps } from '~/navigation/OnboardingNavigator';
|
||||
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './GetStarted';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, { message: 'Library name is required' })
|
||||
});
|
||||
import { useOnboardingContext } from './context';
|
||||
|
||||
const NewLibraryScreen = ({ navigation }: OnboardingStackScreenProps<'NewLibrary'>) => {
|
||||
const obStore = useOnboardingStore();
|
||||
const form = useOnboardingContext().forms.useForm('NewLibrary');
|
||||
|
||||
const form = useZodForm({
|
||||
schema,
|
||||
defaultValues: {
|
||||
name: obStore.newLibraryName
|
||||
}
|
||||
});
|
||||
|
||||
const handleNewLibrary = form.handleSubmit(async (data) => {
|
||||
getOnboardingStore().newLibraryName = data.name;
|
||||
navigation.navigate('Privacy');
|
||||
});
|
||||
const handleNewLibrary = form.handleSubmit(() => navigation.navigate('Privacy'));
|
||||
|
||||
const handleImport = () => {
|
||||
Alert.alert('TODO');
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Pressable, Text, View, ViewStyle } from 'react-native';
|
||||
import { getOnboardingStore } from '@sd/client';
|
||||
import { Button } from '~/components/primitive/Button';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { OnboardingStackScreenProps } from '~/navigation/OnboardingNavigator';
|
||||
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './GetStarted';
|
||||
import { useOnboardingContext } from './context';
|
||||
|
||||
type RadioButtonProps = {
|
||||
title: string;
|
||||
|
@ -38,15 +39,10 @@ const RadioButton = ({ title, description, isSelected, style }: RadioButtonProps
|
|||
);
|
||||
};
|
||||
|
||||
const PrivacyScreen = ({ navigation }: OnboardingStackScreenProps<'Privacy'>) => {
|
||||
const [shareTelemetry, setShareTelemetry] = useState<'share-telemetry' | 'share-minimal'>(
|
||||
'share-telemetry'
|
||||
);
|
||||
const PrivacyScreen = () => {
|
||||
const { forms, submit } = useOnboardingContext();
|
||||
|
||||
const onPress = () => {
|
||||
getOnboardingStore().shareFullTelemetry = shareTelemetry === 'share-telemetry';
|
||||
navigation.navigate('CreatingLibrary');
|
||||
};
|
||||
const form = forms.useForm('Privacy');
|
||||
|
||||
return (
|
||||
<OnboardingContainer>
|
||||
|
@ -56,26 +52,34 @@ const PrivacyScreen = ({ navigation }: OnboardingStackScreenProps<'Privacy'>) =>
|
|||
we'll make it very clear what data is shared with us.
|
||||
</OnboardingDescription>
|
||||
<View style={tw`w-full`}>
|
||||
<Pressable onPress={() => setShareTelemetry('share-telemetry')}>
|
||||
<RadioButton
|
||||
title="Share anonymous usage"
|
||||
description="Share completely anonymous telemetry data to help the developers improve the app"
|
||||
isSelected={shareTelemetry === 'share-telemetry'}
|
||||
style={tw`mb-3 mt-4`}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="share-minimal"
|
||||
onPress={() => setShareTelemetry('share-minimal')}
|
||||
>
|
||||
<RadioButton
|
||||
title="Share the bare minimum"
|
||||
description="Only share that I am an active user of Spacedrive and a few technical bits"
|
||||
isSelected={shareTelemetry === 'share-minimal'}
|
||||
/>
|
||||
</Pressable>
|
||||
<Controller
|
||||
name="shareTelemetry"
|
||||
control={form.control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<>
|
||||
<Pressable onPress={() => onChange('share-telemetry')}>
|
||||
<RadioButton
|
||||
title="Share anonymous usage"
|
||||
description="Share completely anonymous telemetry data to help the developers improve the app"
|
||||
isSelected={value === 'share-telemetry'}
|
||||
style={tw`mb-3 mt-4`}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="share-minimal"
|
||||
onPress={() => onChange('minimal-telemetry')}
|
||||
>
|
||||
<RadioButton
|
||||
title="Share the bare minimum"
|
||||
description="Only share that I am an active user of Spacedrive and a few technical bits"
|
||||
isSelected={value === 'minimal-telemetry'}
|
||||
/>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<Button variant="accent" size="sm" onPress={onPress} style={tw`mt-6`}>
|
||||
<Button variant="accent" size="sm" onPress={form.handleSubmit(submit)} style={tw`mt-6`}>
|
||||
<Text style={tw`text-center text-base font-medium text-ink`}>Continue</Text>
|
||||
</Button>
|
||||
</OnboardingContainer>
|
||||
|
|
120
apps/mobile/src/screens/onboarding/context.tsx
Normal file
120
apps/mobile/src/screens/onboarding/context.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { createContext, useContext } from 'react';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
currentLibraryCache,
|
||||
getOnboardingStore,
|
||||
resetOnboardingStore,
|
||||
telemetryStore,
|
||||
useBridgeMutation,
|
||||
useCachedLibraries,
|
||||
useMultiZodForm,
|
||||
useOnboardingStore,
|
||||
usePlausibleEvent
|
||||
} from '@sd/client';
|
||||
import { OnboardingStackScreenProps } from '~/navigation/OnboardingNavigator';
|
||||
import { currentLibraryStore } from '~/utils/nav';
|
||||
|
||||
export const OnboardingContext = createContext<ReturnType<typeof useContextValue> | null>(null);
|
||||
|
||||
// Hook for generating the value to put into `OnboardingContext.Provider`,
|
||||
// having it separate removes the need for a dedicated context type.
|
||||
export const useContextValue = () => {
|
||||
const libraries = useCachedLibraries();
|
||||
const library =
|
||||
libraries.data?.find((l) => l.uuid === currentLibraryCache.id) || libraries.data?.[0];
|
||||
|
||||
const form = useFormState();
|
||||
|
||||
return {
|
||||
...form,
|
||||
libraries,
|
||||
library
|
||||
};
|
||||
};
|
||||
|
||||
export const shareTelemetrySchema = z.union([
|
||||
z.literal('share-telemetry'),
|
||||
z.literal('minimal-telemetry')
|
||||
]);
|
||||
|
||||
const schemas = {
|
||||
NewLibrary: z.object({
|
||||
name: z.string().min(1, 'Name is required').regex(/[\S]/g).trim()
|
||||
}),
|
||||
Privacy: z.object({
|
||||
shareTelemetry: shareTelemetrySchema
|
||||
})
|
||||
};
|
||||
|
||||
const useFormState = () => {
|
||||
const obStore = useOnboardingStore();
|
||||
|
||||
const { handleSubmit, ...forms } = useMultiZodForm({
|
||||
schemas,
|
||||
defaultValues: {
|
||||
NewLibrary: obStore.data?.['new-library'] ?? undefined,
|
||||
Privacy: obStore.data?.privacy ?? {
|
||||
shareTelemetry: 'share-telemetry'
|
||||
}
|
||||
},
|
||||
onData: (data) => (getOnboardingStore().data = data)
|
||||
});
|
||||
|
||||
const navigation = useNavigation<OnboardingStackScreenProps<any>['navigation']>();
|
||||
const queryClient = useQueryClient();
|
||||
const submitPlausibleEvent = usePlausibleEvent();
|
||||
|
||||
const createLibrary = useBridgeMutation('library.create');
|
||||
|
||||
const submit = handleSubmit(
|
||||
async (data) => {
|
||||
navigation.navigate('CreatingLibrary');
|
||||
|
||||
// opted to place this here as users could change their mind before library creation/onboarding finalization
|
||||
// it feels more fitting to configure it here (once)
|
||||
telemetryStore.shareFullTelemetry = data.Privacy.shareTelemetry === 'share-telemetry';
|
||||
|
||||
try {
|
||||
// show creation screen for a bit for smoothness
|
||||
const [library] = await Promise.all([
|
||||
createLibrary.mutateAsync({
|
||||
name: data.NewLibrary.name
|
||||
}),
|
||||
new Promise((res) => setTimeout(res, 500))
|
||||
]);
|
||||
|
||||
queryClient.setQueryData(['library.list'], (libraries: any) => [
|
||||
...(libraries ?? []),
|
||||
library
|
||||
]);
|
||||
|
||||
if (telemetryStore.shareFullTelemetry) {
|
||||
submitPlausibleEvent({ event: { type: 'libraryCreate' } });
|
||||
}
|
||||
|
||||
resetOnboardingStore();
|
||||
|
||||
// Switch to the new library
|
||||
currentLibraryStore.id = library.uuid;
|
||||
} catch (e) {
|
||||
// TODO: Show toast
|
||||
resetOnboardingStore();
|
||||
navigation.navigate('GetStarted');
|
||||
}
|
||||
},
|
||||
(key) => navigation.navigate(key)
|
||||
);
|
||||
|
||||
return { submit, forms };
|
||||
};
|
||||
|
||||
export const useOnboardingContext = () => {
|
||||
const ctx = useContext(OnboardingContext);
|
||||
|
||||
if (!ctx)
|
||||
throw new Error('useOnboardingContext must be used within OnboardingContext.Provider');
|
||||
|
||||
return ctx;
|
||||
};
|
|
@ -3,7 +3,8 @@ import { Archive, ArrowsClockwise, Trash } from 'phosphor-react-native';
|
|||
import { useEffect } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Alert, ScrollView, Text, View } from 'react-native';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { z } from 'zod';
|
||||
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
|
||||
import { Input } from '~/components/form/Input';
|
||||
import { Switch } from '~/components/form/Switch';
|
||||
import DeleteLocationModal from '~/components/modal/confirmModals/DeleteLocationModal';
|
||||
|
@ -15,7 +16,6 @@ import {
|
|||
SettingsTitle
|
||||
} from '~/components/settings/SettingsContainer';
|
||||
import { SettingsItem } from '~/components/settings/SettingsItem';
|
||||
import { useZodForm, z } from '~/hooks/useZodForm';
|
||||
import { tw, twStyle } from '~/lib/tailwind';
|
||||
import { type SettingsStackScreenProps } from '~/navigation/SettingsNavigator';
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ import { Trash } from 'phosphor-react-native';
|
|||
import React from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Alert, View } from 'react-native';
|
||||
import { useBridgeMutation, useLibraryContext } from '@sd/client';
|
||||
import { z } from 'zod';
|
||||
import { useBridgeMutation, useLibraryContext, useZodForm } from '@sd/client';
|
||||
import { Input } from '~/components/form/Input';
|
||||
import { Switch } from '~/components/form/Switch';
|
||||
import DeleteLibraryModal from '~/components/modal/confirmModals/DeleteLibraryModal';
|
||||
|
@ -11,15 +12,12 @@ import { Divider } from '~/components/primitive/Divider';
|
|||
import { SettingsContainer, SettingsTitle } from '~/components/settings/SettingsContainer';
|
||||
import { SettingsItem } from '~/components/settings/SettingsItem';
|
||||
import { useAutoForm } from '~/hooks/useAutoForm';
|
||||
import { useZodForm, z } from '~/hooks/useZodForm';
|
||||
import { tw } from '~/lib/tailwind';
|
||||
import { SettingsStackScreenProps } from '~/navigation/SettingsNavigator';
|
||||
|
||||
const schema = z.object({ name: z.string(), description: z.string() });
|
||||
|
||||
const LibraryGeneralSettingsScreen = ({
|
||||
navigation
|
||||
}: SettingsStackScreenProps<'LibraryGeneralSettings'>) => {
|
||||
const LibraryGeneralSettingsScreen = (_: SettingsStackScreenProps<'LibraryGeneralSettings'>) => {
|
||||
const { library } = useLibraryContext();
|
||||
|
||||
const form = useZodForm({
|
||||
|
|
|
@ -10,9 +10,7 @@ export const currentLibraryStore = valtioPersist('sdActiveLibrary', {
|
|||
id: null as string | null
|
||||
});
|
||||
|
||||
export const getActiveRouteFromState = function (
|
||||
state: any
|
||||
): Partial<Route<string, object | undefined>> {
|
||||
export const getActiveRouteFromState = (state: any): Partial<Route<string, object | undefined>> => {
|
||||
if (!state.routes || state.routes.length === 0 || state.index >= state.routes.length) {
|
||||
return state;
|
||||
}
|
||||
|
@ -20,6 +18,6 @@ export const getActiveRouteFromState = function (
|
|||
return getActiveRouteFromState(childActiveRoute);
|
||||
};
|
||||
|
||||
export const getStackNameFromState = function (state: DrawerNavigationState<ParamListBase>) {
|
||||
export const getStackNameFromState = (state: DrawerNavigationState<ParamListBase>) => {
|
||||
return getFocusedRouteNameFromRoute(getActiveRouteFromState(state)) ?? 'OverviewStack';
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { useLibraryMutation } from '@sd/client';
|
||||
import { useLibraryMutation, useZodForm } from '@sd/client';
|
||||
import { CheckBox, Dialog, Tooltip, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm } from '@sd/ui/src/forms';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
locationId: number;
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { FilePath, useLibraryMutation } from '@sd/client';
|
||||
import { Dialog, Slider, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { FilePath, useLibraryMutation, useZodForm } from '@sd/client';
|
||||
import { Dialog, Slider, UseDialogProps, useDialog, z } from '@sd/ui';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
locationId: number;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import { Dialog, TextArea, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { useZodForm } from '@sd/client';
|
||||
import { Dialog, TextArea, UseDialogProps, useDialog, z } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
|
||||
const schema = z.object({
|
||||
|
|
|
@ -2,8 +2,8 @@ import clsx from 'clsx';
|
|||
import { useMotionValueEvent, useScroll } from 'framer-motion';
|
||||
import { CheckCircle } from 'phosphor-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Themes, getThemeStore, useThemeStore } from '@sd/client';
|
||||
import { Button, Form, SwitchField, useZodForm, z } from '@sd/ui';
|
||||
import { Themes, getThemeStore, useThemeStore, useZodForm } from '@sd/client';
|
||||
import { Button, Form, SwitchField, z } from '@sd/ui';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { Heading } from '../Layout';
|
||||
import Setting from '../Setting';
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import { Laptop } from '@sd/assets/icons';
|
||||
import { getDebugState, useBridgeMutation, useBridgeQuery, useDebugState } from '@sd/client';
|
||||
import { Button, Card, Input, Switch, tw } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import {
|
||||
getDebugState,
|
||||
useBridgeMutation,
|
||||
useBridgeQuery,
|
||||
useDebugState,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { Button, Card, Input, Switch, tw, z } from '@sd/ui';
|
||||
import { useDebouncedFormWatch } from '~/hooks';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
import { Heading } from '../Layout';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { MaybeUndefined, useBridgeMutation, useLibraryContext } from '@sd/client';
|
||||
import { Button, Input, Switch, Tooltip, dialogManager } from '@sd/ui';
|
||||
import { useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { MaybeUndefined, useBridgeMutation, useLibraryContext, useZodForm } from '@sd/client';
|
||||
import { Button, Input, Switch, Tooltip, dialogManager, z } from '@sd/ui';
|
||||
import { useDebouncedFormWatch } from '~/hooks';
|
||||
import { Heading } from '../Layout';
|
||||
import Setting from '../Setting';
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||
import { Archive, ArrowsClockwise, Info, Trash } from 'phosphor-react';
|
||||
import { Suspense } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
|
||||
import { useLibraryMutation, useLibraryQuery, useZodForm } from '@sd/client';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
|
@ -14,7 +14,6 @@ import {
|
|||
SwitchField,
|
||||
Tooltip,
|
||||
tw,
|
||||
useZodForm,
|
||||
z
|
||||
} from '@sd/ui';
|
||||
import ModalLayout from '~/app/$libraryId/settings/ModalLayout';
|
||||
|
|
|
@ -8,9 +8,10 @@ import {
|
|||
extractInfoRSPCError,
|
||||
useLibraryMutation,
|
||||
useLibraryQuery,
|
||||
usePlausibleEvent
|
||||
usePlausibleEvent,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { Dialog, ErrorMessage, InputField, UseDialogProps, useDialog, useZodForm, z } from '@sd/ui';
|
||||
import { Dialog, ErrorMessage, InputField, UseDialogProps, useDialog, z } from '@sd/ui';
|
||||
import { showAlertDialog } from '~/components';
|
||||
import { useCallbackToWatchForm } from '~/hooks';
|
||||
import { Platform, usePlatform } from '~/util/Platform';
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { useLibraryMutation, usePlausibleEvent } from '@sd/client';
|
||||
import { useLibraryMutation, usePlausibleEvent, useZodForm } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm } from '@sd/ui/src/forms';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
onSuccess: () => void;
|
||||
|
|
|
@ -8,10 +8,11 @@ import {
|
|||
RuleKind,
|
||||
UnionToTuple,
|
||||
extractInfoRSPCError,
|
||||
useLibraryMutation
|
||||
useLibraryMutation,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import { Button, Card, Divider, Input, Select, SelectOption, Tooltip } from '@sd/ui';
|
||||
import { ErrorMessage, Form, useZodForm, z } from '@sd/ui/src/forms';
|
||||
import { ErrorMessage, Form, z } from '@sd/ui/src/forms';
|
||||
import { InputKinds, RuleInput, validateInput } from './RuleInput';
|
||||
|
||||
const ruleKinds: UnionToTuple<RuleKind> = [
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Object, useLibraryMutation, usePlausibleEvent } from '@sd/client';
|
||||
import { Dialog, InputField, UseDialogProps, useDialog, useZodForm, z } from '@sd/ui';
|
||||
import { Object, useLibraryMutation, usePlausibleEvent, useZodForm } from '@sd/client';
|
||||
import { Dialog, InputField, UseDialogProps, useDialog, z } from '@sd/ui';
|
||||
import { ColorPicker } from '~/components';
|
||||
|
||||
const schema = z.object({
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { useLibraryMutation, usePlausibleEvent } from '@sd/client';
|
||||
import { useLibraryMutation, usePlausibleEvent, useZodForm } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
import { useZodForm } from '@sd/ui/src/forms';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
tagId: number;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Trash } from 'phosphor-react';
|
||||
import { Tag, useLibraryMutation } from '@sd/client';
|
||||
import { Button, Form, InputField, Switch, Tooltip, dialogManager, useZodForm, z } from '@sd/ui';
|
||||
import { Tag, useLibraryMutation, useZodForm } from '@sd/client';
|
||||
import { Button, Form, InputField, Switch, Tooltip, dialogManager, z } from '@sd/ui';
|
||||
import { ColorPicker } from '~/components';
|
||||
import { useDebouncedFormWatch } from '~/hooks';
|
||||
import Setting from '../../Setting';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LibraryConfigWrapped, useBridgeMutation, usePlausibleEvent } from '@sd/client';
|
||||
import { Dialog, InputField, UseDialogProps, useDialog, useZodForm, z } from '@sd/ui';
|
||||
import { LibraryConfigWrapped, useBridgeMutation, usePlausibleEvent, useZodForm } from '@sd/client';
|
||||
import { Dialog, InputField, UseDialogProps, useDialog, z } from '@sd/ui';
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useBridgeMutation, usePlausibleEvent } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog, useZodForm } from '@sd/ui';
|
||||
import { useBridgeMutation, usePlausibleEvent, useZodForm } from '@sd/client';
|
||||
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
libraryUuid: string;
|
||||
|
|
|
@ -8,10 +8,11 @@ import {
|
|||
telemetryStore,
|
||||
useBridgeMutation,
|
||||
useCachedLibraries,
|
||||
useMultiZodForm,
|
||||
useOnboardingStore,
|
||||
usePlausibleEvent
|
||||
} from '@sd/client';
|
||||
import { RadioGroupField, useZodForm, z } from '@sd/ui';
|
||||
import { RadioGroupField, z } from '@sd/ui';
|
||||
|
||||
export const OnboardingContext = createContext<ReturnType<typeof useContextValue> | null>(null);
|
||||
|
||||
|
@ -46,21 +47,27 @@ export const shareTelemetry = RadioGroupField.options([
|
|||
}
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required').regex(/[\S]/g).trim(),
|
||||
shareTelemetry: shareTelemetry.schema
|
||||
});
|
||||
const schemas = {
|
||||
'new-library': z.object({
|
||||
name: z.string().min(1, 'Name is required').regex(/[\S]/g).trim()
|
||||
}),
|
||||
'privacy': z.object({
|
||||
shareTelemetry: shareTelemetry.schema
|
||||
})
|
||||
};
|
||||
|
||||
// this is a lot so it gets its own hook :)
|
||||
const useFormState = () => {
|
||||
const obStore = useOnboardingStore();
|
||||
|
||||
const form = useZodForm({
|
||||
schema,
|
||||
const { handleSubmit, ...forms } = useMultiZodForm({
|
||||
schemas,
|
||||
defaultValues: {
|
||||
name: obStore.newLibraryName,
|
||||
shareTelemetry: 'share-telemetry'
|
||||
}
|
||||
'new-library': obStore.data?.['new-library'] ?? undefined,
|
||||
'privacy': obStore.data?.privacy ?? {
|
||||
shareTelemetry: 'share-telemetry'
|
||||
}
|
||||
},
|
||||
onData: (data) => (getOnboardingStore().data = data)
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
@ -69,42 +76,45 @@ const useFormState = () => {
|
|||
|
||||
const createLibrary = useBridgeMutation('library.create');
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
navigate('./creating-library', { replace: true });
|
||||
const submit = handleSubmit(
|
||||
async (data) => {
|
||||
navigate('./creating-library', { replace: true });
|
||||
|
||||
// opted to place this here as users could change their mind before library creation/onboarding finalization
|
||||
// it feels more fitting to configure it here (once)
|
||||
telemetryStore.shareFullTelemetry = getOnboardingStore().shareFullTelemetry;
|
||||
// opted to place this here as users could change their mind before library creation/onboarding finalization
|
||||
// it feels more fitting to configure it here (once)
|
||||
telemetryStore.shareFullTelemetry = data.privacy.shareTelemetry === 'share-telemetry';
|
||||
|
||||
try {
|
||||
// show creation screen for a bit for smoothness
|
||||
const [library] = await Promise.all([
|
||||
createLibrary.mutateAsync({
|
||||
name: data.name
|
||||
}),
|
||||
new Promise((res) => setTimeout(res, 500))
|
||||
]);
|
||||
try {
|
||||
// show creation screen for a bit for smoothness
|
||||
const [library] = await Promise.all([
|
||||
createLibrary.mutateAsync({
|
||||
name: data['new-library'].name
|
||||
}),
|
||||
new Promise((res) => setTimeout(res, 500))
|
||||
]);
|
||||
|
||||
queryClient.setQueryData(['library.list'], (libraries: any) => [
|
||||
...(libraries ?? []),
|
||||
library
|
||||
]);
|
||||
queryClient.setQueryData(['library.list'], (libraries: any) => [
|
||||
...(libraries ?? []),
|
||||
library
|
||||
]);
|
||||
|
||||
if (telemetryStore.shareFullTelemetry) {
|
||||
submitPlausibleEvent({ event: { type: 'libraryCreate' } });
|
||||
if (telemetryStore.shareFullTelemetry) {
|
||||
submitPlausibleEvent({ event: { type: 'libraryCreate' } });
|
||||
}
|
||||
|
||||
resetOnboardingStore();
|
||||
navigate(`/${library.uuid}/overview`, { replace: true });
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
alert(`Failed to create library. Error: ${e.message}`);
|
||||
}
|
||||
navigate('./privacy');
|
||||
}
|
||||
},
|
||||
(key) => navigate(`./${key}`)
|
||||
);
|
||||
|
||||
resetOnboardingStore();
|
||||
navigate(`/${library.uuid}/overview`, { replace: true });
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
alert(`Failed to create library. Error: ${e.message}`);
|
||||
}
|
||||
navigate('./privacy');
|
||||
}
|
||||
});
|
||||
|
||||
return { form, onSubmit };
|
||||
return { submit, forms };
|
||||
};
|
||||
|
||||
export const useOnboardingContext = () => {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { Database } from '@sd/assets/icons';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getOnboardingStore } from '@sd/client';
|
||||
import { Button, Form, InputField } from '@sd/ui';
|
||||
import {
|
||||
OnboardingContainer,
|
||||
|
@ -13,7 +12,8 @@ import { useOnboardingContext } from './context';
|
|||
|
||||
export default function OnboardingNewLibrary() {
|
||||
const navigate = useNavigate();
|
||||
const { form } = useOnboardingContext();
|
||||
const form = useOnboardingContext().forms.useForm('new-library');
|
||||
|
||||
const [importMode, setImportMode] = useState(false);
|
||||
|
||||
const handleImport = () => {
|
||||
|
@ -23,11 +23,9 @@ export default function OnboardingNewLibrary() {
|
|||
return (
|
||||
<Form
|
||||
form={form}
|
||||
// manual onSubmit as we need to set the library name in the store
|
||||
onSubmit={async () => {
|
||||
getOnboardingStore().newLibraryName = form.getValues('name');
|
||||
onSubmit={form.handleSubmit(() => {
|
||||
navigate('../privacy', { replace: true });
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<OnboardingContainer>
|
||||
<OnboardingImg src={Database} />
|
||||
|
@ -53,17 +51,13 @@ export default function OnboardingNewLibrary() {
|
|||
{...form.register('name')}
|
||||
size="lg"
|
||||
autoFocus
|
||||
disabled={form.formState.isValid}
|
||||
className="mt-6 w-[300px]"
|
||||
placeholder={'e.g. "James\' Library"'}
|
||||
/>
|
||||
<div className="flex grow" />
|
||||
<div className="mt-7 space-x-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="accent"
|
||||
disabled={!form.formState.isValid}
|
||||
size="sm"
|
||||
>
|
||||
<Button type="submit" variant="accent" size="sm">
|
||||
New library
|
||||
</Button>
|
||||
{/* <span className="px-2 text-xs font-bold text-ink-faint">OR</span>
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import { Button, Form, RadioGroupField } from '@sd/ui';
|
||||
import { getOnboardingStore } from '~/../packages/client/src';
|
||||
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './Layout';
|
||||
import { shareTelemetry, useOnboardingContext } from './context';
|
||||
|
||||
export default function OnboardingPrivacy() {
|
||||
const { form, onSubmit } = useOnboardingContext();
|
||||
const { forms, submit } = useOnboardingContext();
|
||||
|
||||
const form = forms.useForm('privacy');
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={(e) => {
|
||||
getOnboardingStore().shareFullTelemetry =
|
||||
form.getValues('shareTelemetry') === 'share-telemetry';
|
||||
return onSubmit(e);
|
||||
}}
|
||||
onSubmit={form.handleSubmit(submit)}
|
||||
className="flex flex-col items-center"
|
||||
>
|
||||
<OnboardingContainer>
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
useDiscoveredPeers,
|
||||
useFeatureFlag,
|
||||
useP2PEvents,
|
||||
useZodForm,
|
||||
withFeatureFlag
|
||||
} from '@sd/client';
|
||||
import {
|
||||
|
@ -15,7 +16,6 @@ import {
|
|||
UseDialogProps,
|
||||
dialogManager,
|
||||
useDialog,
|
||||
useZodForm,
|
||||
z
|
||||
} from '@sd/ui';
|
||||
import { getSpacedropState, subscribeSpacedropState } from '../../hooks/useSpacedropState';
|
||||
|
|
|
@ -4,7 +4,8 @@ import {
|
|||
OperatingSystem,
|
||||
useBridgeMutation,
|
||||
useCachedLibraries,
|
||||
usePairingStatus
|
||||
usePairingStatus,
|
||||
useZodForm
|
||||
} from '@sd/client';
|
||||
import {
|
||||
Button,
|
||||
|
@ -15,7 +16,6 @@ import {
|
|||
UseDialogProps,
|
||||
dialogManager,
|
||||
useDialog,
|
||||
useZodForm,
|
||||
z
|
||||
} from '@sd/ui';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Clipboard } from 'phosphor-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { useZodForm } from '@sd/client';
|
||||
import { Button, Dialog, Input, UseDialogProps, dialogManager, useDialog } from '@sd/ui';
|
||||
import { useZodForm } from '@sd/ui/src/forms';
|
||||
|
||||
interface Props extends UseDialogProps {
|
||||
title: string; // dialog title
|
||||
|
|
73
packages/client/src/form.ts
Normal file
73
packages/client/src/form.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { UseFormProps, useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface UseZodFormProps<S extends z.ZodObject<any>>
|
||||
extends Exclude<UseFormProps<z.infer<S>>, 'resolver'> {
|
||||
schema?: S;
|
||||
}
|
||||
|
||||
export function useZodForm<S extends z.ZodObject<any>>(props?: UseZodFormProps<S>) {
|
||||
const { schema, ...formProps } = props ?? {};
|
||||
|
||||
return useForm<z.infer<S>>({
|
||||
...formProps,
|
||||
resolver: zodResolver(schema || z.object({}))
|
||||
});
|
||||
}
|
||||
|
||||
export function useMultiZodForm<S extends Record<string, z.ZodObject<any>>>({
|
||||
schemas,
|
||||
defaultValues,
|
||||
onData
|
||||
}: {
|
||||
schemas: S;
|
||||
defaultValues: {
|
||||
[K in keyof S]?: UseZodFormProps<S[K]>['defaultValues'];
|
||||
};
|
||||
onData?: (data: { [K in keyof S]?: z.infer<S[K]> }) => any;
|
||||
}) {
|
||||
const formsData = useRef<{ [K in keyof S]?: z.infer<S[K]> }>({});
|
||||
|
||||
return {
|
||||
useForm<K extends keyof S>(
|
||||
key: K,
|
||||
props?: Exclude<UseZodFormProps<S[K]>, 'schema' | 'defaultValues'>
|
||||
) {
|
||||
const form = useZodForm({
|
||||
...props,
|
||||
defaultValues: defaultValues[key],
|
||||
schema: schemas[key]
|
||||
});
|
||||
const handleSubmit = form.handleSubmit;
|
||||
|
||||
form.handleSubmit = useCallback(
|
||||
(onValid, onError) =>
|
||||
handleSubmit((data, e) => {
|
||||
formsData.current[key] = data;
|
||||
onData?.(formsData.current);
|
||||
return onValid(data, e);
|
||||
}, onError),
|
||||
[handleSubmit, key]
|
||||
);
|
||||
|
||||
return form;
|
||||
},
|
||||
handleSubmit:
|
||||
(
|
||||
onValid: (data: { [K in keyof S]: z.infer<S[K]> }) => any | Promise<any>,
|
||||
onError?: (key: keyof S) => void
|
||||
) =>
|
||||
() => {
|
||||
for (const key of Object.keys(schemas)) {
|
||||
if (formsData.current[key] === undefined) {
|
||||
onError?.(key);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return onValid(formsData.current as any);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -10,12 +10,11 @@ export enum UseCase {
|
|||
}
|
||||
|
||||
const onboardingStoreDefaults = () => ({
|
||||
newLibraryName: '',
|
||||
unlockedScreens: ['alpha'],
|
||||
lastActiveScreen: null as string | null,
|
||||
shareFullTelemetry: true,
|
||||
useCases: [] as UseCase[],
|
||||
grantedFullDiskAccess: false
|
||||
grantedFullDiskAccess: false,
|
||||
data: {} as Record<string, any> | undefined
|
||||
});
|
||||
|
||||
const appOnboardingStore = valtioPersist('onboarding', onboardingStoreDefaults());
|
||||
|
|
|
@ -19,3 +19,4 @@ export * from './rspc';
|
|||
export * from './core';
|
||||
export * from './utils';
|
||||
export * from './lib';
|
||||
export * from './form';
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { VariantProps, cva } from 'class-variance-authority';
|
||||
import { Warning } from 'phosphor-react';
|
||||
|
@ -8,13 +7,10 @@ import {
|
|||
FieldValues,
|
||||
FormProvider,
|
||||
UseFormHandleSubmit,
|
||||
UseFormProps,
|
||||
UseFormReturn,
|
||||
get,
|
||||
useForm,
|
||||
useFormContext
|
||||
} from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface FormProps<T extends FieldValues> extends Omit<ComponentProps<'form'>, 'onSubmit'> {
|
||||
form: UseFormReturn<T>;
|
||||
|
@ -53,22 +49,6 @@ export const Form = <T extends FieldValues>({
|
|||
);
|
||||
};
|
||||
|
||||
interface UseZodFormProps<S extends z.ZodSchema>
|
||||
extends Exclude<UseFormProps<z.infer<S>>, 'resolver'> {
|
||||
schema?: S;
|
||||
}
|
||||
|
||||
export const useZodForm = <S extends z.ZodSchema = z.ZodObject<Record<string, never>>>(
|
||||
props?: UseZodFormProps<S>
|
||||
) => {
|
||||
const { schema, ...formProps } = props ?? {};
|
||||
|
||||
return useForm<z.infer<S>>({
|
||||
...formProps,
|
||||
resolver: zodResolver(schema || z.object({}))
|
||||
});
|
||||
};
|
||||
|
||||
export const errorStyles = cva(
|
||||
'flex justify-center gap-2 break-all rounded border border-red-500/40 bg-red-800/40 px-3 py-2 text-white',
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue