From 5faaa9f849434207c6df70ceba6c06fbfcb1f2a3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 29 Aug 2023 18:58:39 +0800 Subject: [PATCH] [ENG-1007] Per-page onboarding forms (#1256) * useMultiZodForm * fix imports * handle obStore.data undefined --------- Co-authored-by: Utku <74243531+utkubakir@users.noreply.github.com> --- .../src/navigation/OnboardingNavigator.tsx | 37 +++--- .../screens/onboarding/CreatingLibrary.tsx | 72 +---------- .../src/screens/onboarding/NewLibrary.tsx | 21 +-- .../mobile/src/screens/onboarding/Privacy.tsx | 62 ++++----- .../mobile/src/screens/onboarding/context.tsx | 120 ++++++++++++++++++ .../settings/library/EditLocationSettings.tsx | 4 +- .../library/LibraryGeneralSettings.tsx | 8 +- apps/mobile/src/utils/nav.ts | 6 +- .../Explorer/FilePath/DeleteDialog.tsx | 3 +- .../Explorer/FilePath/EraseDialog.tsx | 5 +- .../Layout/Sidebar/FeedbackDialog.tsx | 4 +- .../$libraryId/settings/client/appearance.tsx | 4 +- .../$libraryId/settings/client/general.tsx | 11 +- .../$libraryId/settings/library/general.tsx | 5 +- .../settings/library/locations/$id.tsx | 3 +- .../library/locations/AddLocationDialog.tsx | 5 +- .../library/locations/DeleteDialog.tsx | 3 +- .../locations/IndexerRuleEditor/RulesForm.tsx | 5 +- .../settings/library/tags/CreateDialog.tsx | 4 +- .../settings/library/tags/DeleteDialog.tsx | 3 +- .../settings/library/tags/EditForm.tsx | 4 +- .../settings/node/libraries/CreateDialog.tsx | 4 +- .../settings/node/libraries/DeleteDialog.tsx | 4 +- interface/app/onboarding/context.tsx | 92 ++++++++------ interface/app/onboarding/new-library.tsx | 18 +-- interface/app/onboarding/privacy.tsx | 11 +- interface/app/p2p/Spacedrop.tsx | 2 +- interface/app/p2p/pairing.tsx | 4 +- interface/components/AlertDialog.tsx | 2 +- packages/client/src/form.ts | 73 +++++++++++ .../client/src/hooks/useOnboardingStore.ts | 5 +- packages/client/src/index.ts | 1 + packages/ui/src/forms/Form.tsx | 20 --- 33 files changed, 361 insertions(+), 264 deletions(-) create mode 100644 apps/mobile/src/screens/onboarding/context.tsx create mode 100644 packages/client/src/form.ts diff --git a/apps/mobile/src/navigation/OnboardingNavigator.tsx b/apps/mobile/src/navigation/OnboardingNavigator.tsx index 80c951a4a..3a1f763c2 100644 --- a/apps/mobile/src/navigation/OnboardingNavigator.tsx +++ b/apps/mobile/src/navigation/OnboardingNavigator.tsx @@ -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(); export default function OnboardingNavigator() { return ( - - - - - - + + + + + + + + ); } diff --git a/apps/mobile/src/screens/onboarding/CreatingLibrary.tsx b/apps/mobile/src/screens/onboarding/CreatingLibrary.tsx index fda13913b..988749352 100644 --- a/apps/mobile/src/screens/onboarding/CreatingLibrary.tsx +++ b/apps/mobile/src/screens/onboarding/CreatingLibrary.tsx @@ -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 ( 🛠 Creating your library - {status} + Creating your library... ); diff --git a/apps/mobile/src/screens/onboarding/NewLibrary.tsx b/apps/mobile/src/screens/onboarding/NewLibrary.tsx index 483dfcfb2..0f293e21e 100644 --- a/apps/mobile/src/screens/onboarding/NewLibrary.tsx +++ b/apps/mobile/src/screens/onboarding/NewLibrary.tsx @@ -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'); diff --git a/apps/mobile/src/screens/onboarding/Privacy.tsx b/apps/mobile/src/screens/onboarding/Privacy.tsx index 3cb60142f..7055988ba 100644 --- a/apps/mobile/src/screens/onboarding/Privacy.tsx +++ b/apps/mobile/src/screens/onboarding/Privacy.tsx @@ -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 ( @@ -56,26 +52,34 @@ const PrivacyScreen = ({ navigation }: OnboardingStackScreenProps<'Privacy'>) => we'll make it very clear what data is shared with us. - setShareTelemetry('share-telemetry')}> - - - setShareTelemetry('share-minimal')} - > - - + ( + <> + onChange('share-telemetry')}> + + + onChange('minimal-telemetry')} + > + + + + )} + /> - diff --git a/apps/mobile/src/screens/onboarding/context.tsx b/apps/mobile/src/screens/onboarding/context.tsx new file mode 100644 index 000000000..730720099 --- /dev/null +++ b/apps/mobile/src/screens/onboarding/context.tsx @@ -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 | 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['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; +}; diff --git a/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx b/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx index f7029e770..867673aa4 100644 --- a/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx +++ b/apps/mobile/src/screens/settings/library/EditLocationSettings.tsx @@ -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'; diff --git a/apps/mobile/src/screens/settings/library/LibraryGeneralSettings.tsx b/apps/mobile/src/screens/settings/library/LibraryGeneralSettings.tsx index 6e44a46ff..12f3c36e3 100644 --- a/apps/mobile/src/screens/settings/library/LibraryGeneralSettings.tsx +++ b/apps/mobile/src/screens/settings/library/LibraryGeneralSettings.tsx @@ -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({ diff --git a/apps/mobile/src/utils/nav.ts b/apps/mobile/src/utils/nav.ts index f9d260de6..422544c76 100644 --- a/apps/mobile/src/utils/nav.ts +++ b/apps/mobile/src/utils/nav.ts @@ -10,9 +10,7 @@ export const currentLibraryStore = valtioPersist('sdActiveLibrary', { id: null as string | null }); -export const getActiveRouteFromState = function ( - state: any -): Partial> { +export const getActiveRouteFromState = (state: any): Partial> => { 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) { +export const getStackNameFromState = (state: DrawerNavigationState) => { return getFocusedRouteNameFromRoute(getActiveRouteFromState(state)) ?? 'OverviewStack'; }; diff --git a/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx b/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx index 517b0b950..49f50b6d6 100644 --- a/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/DeleteDialog.tsx @@ -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; diff --git a/interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx b/interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx index 5a7e4a5ec..68324885d 100644 --- a/interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx +++ b/interface/app/$libraryId/Explorer/FilePath/EraseDialog.tsx @@ -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; diff --git a/interface/app/$libraryId/Layout/Sidebar/FeedbackDialog.tsx b/interface/app/$libraryId/Layout/Sidebar/FeedbackDialog.tsx index 2c3703378..fb4874c4b 100644 --- a/interface/app/$libraryId/Layout/Sidebar/FeedbackDialog.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/FeedbackDialog.tsx @@ -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({ diff --git a/interface/app/$libraryId/settings/client/appearance.tsx b/interface/app/$libraryId/settings/client/appearance.tsx index be1cab240..6908ae857 100644 --- a/interface/app/$libraryId/settings/client/appearance.tsx +++ b/interface/app/$libraryId/settings/client/appearance.tsx @@ -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'; diff --git a/interface/app/$libraryId/settings/client/general.tsx b/interface/app/$libraryId/settings/client/general.tsx index d93d00c42..9c2f85870 100644 --- a/interface/app/$libraryId/settings/client/general.tsx +++ b/interface/app/$libraryId/settings/client/general.tsx @@ -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'; diff --git a/interface/app/$libraryId/settings/library/general.tsx b/interface/app/$libraryId/settings/library/general.tsx index c85e33acb..3a24c7767 100644 --- a/interface/app/$libraryId/settings/library/general.tsx +++ b/interface/app/$libraryId/settings/library/general.tsx @@ -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'; diff --git a/interface/app/$libraryId/settings/library/locations/$id.tsx b/interface/app/$libraryId/settings/library/locations/$id.tsx index 58e8824d8..86250814a 100644 --- a/interface/app/$libraryId/settings/library/locations/$id.tsx +++ b/interface/app/$libraryId/settings/library/locations/$id.tsx @@ -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'; diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx index 042b9d31d..7c8ba9008 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx @@ -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'; diff --git a/interface/app/$libraryId/settings/library/locations/DeleteDialog.tsx b/interface/app/$libraryId/settings/library/locations/DeleteDialog.tsx index db713340e..b8d910695 100644 --- a/interface/app/$libraryId/settings/library/locations/DeleteDialog.tsx +++ b/interface/app/$libraryId/settings/library/locations/DeleteDialog.tsx @@ -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; diff --git a/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/RulesForm.tsx b/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/RulesForm.tsx index 94408ebb6..694129729 100644 --- a/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/RulesForm.tsx +++ b/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor/RulesForm.tsx @@ -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 = [ diff --git a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx index 0743259fa..48e45cc32 100644 --- a/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx +++ b/interface/app/$libraryId/settings/library/tags/CreateDialog.tsx @@ -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({ diff --git a/interface/app/$libraryId/settings/library/tags/DeleteDialog.tsx b/interface/app/$libraryId/settings/library/tags/DeleteDialog.tsx index 409bea5da..f8693da41 100644 --- a/interface/app/$libraryId/settings/library/tags/DeleteDialog.tsx +++ b/interface/app/$libraryId/settings/library/tags/DeleteDialog.tsx @@ -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; diff --git a/interface/app/$libraryId/settings/library/tags/EditForm.tsx b/interface/app/$libraryId/settings/library/tags/EditForm.tsx index 70be40198..95655cf8f 100644 --- a/interface/app/$libraryId/settings/library/tags/EditForm.tsx +++ b/interface/app/$libraryId/settings/library/tags/EditForm.tsx @@ -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'; diff --git a/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx b/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx index c5700133d..518431f22 100644 --- a/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx +++ b/interface/app/$libraryId/settings/node/libraries/CreateDialog.tsx @@ -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 diff --git a/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx b/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx index b5202b3e7..1be212f4c 100644 --- a/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx +++ b/interface/app/$libraryId/settings/node/libraries/DeleteDialog.tsx @@ -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; diff --git a/interface/app/onboarding/context.tsx b/interface/app/onboarding/context.tsx index 1d92ffa17..ecea64b25 100644 --- a/interface/app/onboarding/context.tsx +++ b/interface/app/onboarding/context.tsx @@ -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 | 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 = () => { diff --git a/interface/app/onboarding/new-library.tsx b/interface/app/onboarding/new-library.tsx index dfddab6c9..1be2cfac3 100644 --- a/interface/app/onboarding/new-library.tsx +++ b/interface/app/onboarding/new-library.tsx @@ -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 (
{ - getOnboardingStore().newLibraryName = form.getValues('name'); + onSubmit={form.handleSubmit(() => { navigate('../privacy', { replace: true }); - }} + })} > @@ -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"'} />
- {/* OR diff --git a/interface/app/onboarding/privacy.tsx b/interface/app/onboarding/privacy.tsx index 26bd51dfa..dac7a256c 100644 --- a/interface/app/onboarding/privacy.tsx +++ b/interface/app/onboarding/privacy.tsx @@ -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 ( { - getOnboardingStore().shareFullTelemetry = - form.getValues('shareTelemetry') === 'share-telemetry'; - return onSubmit(e); - }} + onSubmit={form.handleSubmit(submit)} className="flex flex-col items-center" > diff --git a/interface/app/p2p/Spacedrop.tsx b/interface/app/p2p/Spacedrop.tsx index 42b327cb7..b6e80463f 100644 --- a/interface/app/p2p/Spacedrop.tsx +++ b/interface/app/p2p/Spacedrop.tsx @@ -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'; diff --git a/interface/app/p2p/pairing.tsx b/interface/app/p2p/pairing.tsx index 287aff645..ecf69488b 100644 --- a/interface/app/p2p/pairing.tsx +++ b/interface/app/p2p/pairing.tsx @@ -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'; diff --git a/interface/components/AlertDialog.tsx b/interface/components/AlertDialog.tsx index 11e74408c..eb111c98d 100644 --- a/interface/components/AlertDialog.tsx +++ b/interface/components/AlertDialog.tsx @@ -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 diff --git a/packages/client/src/form.ts b/packages/client/src/form.ts new file mode 100644 index 000000000..86b7f1c28 --- /dev/null +++ b/packages/client/src/form.ts @@ -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> + extends Exclude>, 'resolver'> { + schema?: S; +} + +export function useZodForm>(props?: UseZodFormProps) { + const { schema, ...formProps } = props ?? {}; + + return useForm>({ + ...formProps, + resolver: zodResolver(schema || z.object({})) + }); +} + +export function useMultiZodForm>>({ + schemas, + defaultValues, + onData +}: { + schemas: S; + defaultValues: { + [K in keyof S]?: UseZodFormProps['defaultValues']; + }; + onData?: (data: { [K in keyof S]?: z.infer }) => any; +}) { + const formsData = useRef<{ [K in keyof S]?: z.infer }>({}); + + return { + useForm( + key: K, + props?: Exclude, '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 }) => any | Promise, + 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); + } + }; +} diff --git a/packages/client/src/hooks/useOnboardingStore.ts b/packages/client/src/hooks/useOnboardingStore.ts index 34dee79fe..a13b20b4e 100644 --- a/packages/client/src/hooks/useOnboardingStore.ts +++ b/packages/client/src/hooks/useOnboardingStore.ts @@ -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 | undefined }); const appOnboardingStore = valtioPersist('onboarding', onboardingStoreDefaults()); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e53cc84b5..c62691a9e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -19,3 +19,4 @@ export * from './rspc'; export * from './core'; export * from './utils'; export * from './lib'; +export * from './form'; diff --git a/packages/ui/src/forms/Form.tsx b/packages/ui/src/forms/Form.tsx index e9e299b0f..b85c26775 100644 --- a/packages/ui/src/forms/Form.tsx +++ b/packages/ui/src/forms/Form.tsx @@ -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 extends Omit, 'onSubmit'> { form: UseFormReturn; @@ -53,22 +49,6 @@ export const Form = ({ ); }; -interface UseZodFormProps - extends Exclude>, 'resolver'> { - schema?: S; -} - -export const useZodForm = >>( - props?: UseZodFormProps -) => { - const { schema, ...formProps } = props ?? {}; - - return useForm>({ - ...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', {