[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:
Brendan Allan 2023-08-29 18:58:39 +08:00 committed by GitHub
parent 08ba4f917a
commit 795bb18d18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 361 additions and 264 deletions

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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');

View file

@ -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>

View 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;
};

View file

@ -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';

View file

@ -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({

View file

@ -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';
};

View file

@ -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;

View file

@ -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;

View file

@ -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({

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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;

View file

@ -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> = [

View file

@ -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({

View file

@ -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;

View file

@ -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';

View file

@ -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

View file

@ -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;

View file

@ -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 = () => {

View file

@ -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>

View file

@ -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>

View file

@ -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';

View file

@ -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';

View file

@ -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

View 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);
}
};
}

View file

@ -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());

View file

@ -19,3 +19,4 @@ export * from './rspc';
export * from './core';
export * from './utils';
export * from './lib';
export * from './form';

View file

@ -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',
{