From ae94ada4f89022b4d587f76ffaa2b23bfe748582 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 12 Jan 2023 07:26:59 -0800 Subject: [PATCH] Begin form abstraction (#515) --- Cargo.lock | Bin 188933 -> 188828 bytes Cargo.toml | 6 +- apps/desktop/package.json | 1 - crates/sync/src/lib.rs | 6 +- packages/interface/package.json | 6 +- .../components/dialog/AddLocationDialog.tsx | 49 +-- .../src/components/dialog/AlertDialog.tsx | 10 +- .../components/dialog/BackupRestoreDialog.tsx | 160 ++++---- .../components/dialog/CreateLibraryDialog.tsx | 341 +++++++++--------- .../components/dialog/DecryptFileDialog.tsx | 232 ++++++------ .../components/dialog/DeleteLibraryDialog.tsx | 26 +- .../components/dialog/EncryptFileDialog.tsx | 223 ++++++------ .../src/components/dialog/KeyViewerDialog.tsx | 183 +++++----- .../dialog/MasterPasswordChangeDialog.tsx | 304 ++++++++-------- .../src/components/explorer/Explorer.tsx | 10 +- .../components/explorer/ExplorerTopBar.tsx | 22 +- .../components/location/LocationListItem.tsx | 11 +- .../src/components/onboarding/Onboarding.tsx | 2 +- .../src/components/primitive/Checkbox.tsx | 21 -- .../components/primitive/PopoverPicker.tsx | 12 +- .../screens/settings/library/TagsSettings.tsx | 100 +++-- packages/ui/package.json | 3 +- packages/ui/src/Button.tsx | 11 +- packages/ui/src/CheckBox.tsx | 18 + packages/ui/src/Dialog.tsx | 75 +--- packages/ui/src/Input.tsx | 18 +- packages/ui/src/Switch.tsx | 23 +- packages/ui/src/forms/CheckBox.tsx | 16 + packages/ui/src/forms/Form.tsx | 50 +++ packages/ui/src/forms/FormField.tsx | 28 ++ packages/ui/src/forms/Input.tsx | 18 + packages/ui/src/forms/Switch.tsx | 20 + packages/ui/src/forms/index.ts | 5 + packages/ui/src/index.ts | 2 + pnpm-lock.yaml | Bin 786428 -> 785866 bytes 35 files changed, 1049 insertions(+), 963 deletions(-) delete mode 100644 packages/interface/src/components/primitive/Checkbox.tsx create mode 100644 packages/ui/src/CheckBox.tsx create mode 100644 packages/ui/src/forms/CheckBox.tsx create mode 100644 packages/ui/src/forms/Form.tsx create mode 100644 packages/ui/src/forms/FormField.tsx create mode 100644 packages/ui/src/forms/Input.tsx create mode 100644 packages/ui/src/forms/Switch.tsx create mode 100644 packages/ui/src/forms/index.ts diff --git a/Cargo.lock b/Cargo.lock index a210d57c59c22e2251e12b261153ba91fb693326..66131b14ab586bea2b02724b853b7f1e039251b5 100644 GIT binary patch delta 253 zcmZoY!ae6Ocf%IO)|u0#jxh30pR<5bd-~p)i~$js7AnMj3`8Qa9BZ+OEfuw7*q<1$A2*cUm6@e^Han$*GH P{&Ftk_Lp;+Hai0VZ { +pub trait CreateCRDTMutation { fn operation_from_data( d: &BTreeMap, typ: CreateOperationType, @@ -22,7 +22,7 @@ pub enum CreateOperationType { Relation, } -impl CreateCRDTMutation for prisma_client_rust::Create<'_, T> { +impl CreateCRDTMutation for prisma_client_rust::Create<'_, T> { fn operation_from_data( _: &BTreeMap, typ: CreateOperationType, diff --git a/packages/interface/package.json b/packages/interface/package.json index 50d3acf89..42958f85b 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -20,6 +20,7 @@ "dependencies": { "@fontsource/inter": "^4.5.13", "@headlessui/react": "^1.7.3", + "@hookform/resolvers": "^2.9.10", "@loadable/component": "^5.15.2", "@radix-ui/react-progress": "^1.0.1", "@radix-ui/react-slider": "^1.1.0", @@ -41,6 +42,7 @@ "@zxcvbn-ts/language-en": "^2.1.0", "autoprefixer": "^10.4.12", "byte-size": "^8.1.0", + "class-variance-authority": "^0.4.0", "clsx": "^1.2.1", "crypto-random-string": "^5.0.0", "dayjs": "^1.11.5", @@ -58,7 +60,8 @@ "tailwindcss": "^3.1.8", "use-count-up": "^3.0.1", "use-debounce": "^8.0.4", - "valtio": "^1.7.4" + "valtio": "^1.7.4", + "zod": "^3.20.2" }, "devDependencies": { "@sd/config": "workspace:*", @@ -69,7 +72,6 @@ "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", "@types/react-router-dom": "^5.3.3", - "@types/tailwindcss": "^3.1.0", "@vitejs/plugin-react": "^1.3.1", "prettier": "^2.7.1", "typescript": "^4.8.4", diff --git a/packages/interface/src/components/dialog/AddLocationDialog.tsx b/packages/interface/src/components/dialog/AddLocationDialog.tsx index 4a803da2d..3bda286aa 100644 --- a/packages/interface/src/components/dialog/AddLocationDialog.tsx +++ b/packages/interface/src/components/dialog/AddLocationDialog.tsx @@ -1,47 +1,52 @@ -import { LocationCreateArgs, useBridgeMutation, useLibraryMutation } from '@sd/client'; +import { useLibraryMutation } from '@sd/client'; import { Input } from '@sd/ui'; import { Dialog } from '@sd/ui'; -import { useQueryClient } from '@tanstack/react-query'; -import { PropsWithChildren, useState } from 'react'; +import { forms } from '@sd/ui'; -export default function AddLocationDialog({ - children, - onSubmit, - open, - setOpen -}: PropsWithChildren<{ onSubmit?: () => void; open: boolean; setOpen: (state: boolean) => void }>) { - // BEFORE MERGE: Remove default value - const [locationUrl, setLocationUrl] = useState( - '/Users/jamie/Projects/spacedrive/packages/test-files/files' - ); +const { useZodForm, z } = forms; +const schema = z.object({ path: z.string() }); + +interface Props { + open: boolean; + setOpen: (state: boolean) => void; +} + +export default function AddLocationDialog({ open, setOpen }: Props) { const createLocation = useLibraryMutation('locations.create', { onSuccess: () => setOpen(false) }); + const form = useZodForm({ + schema, + defaultValues: { + // BEFORE MERGE: Remove default value + path: '/Users/jamie/Projects/spacedrive/packages/test-files/files' + } + }); + return ( { + await createLocation.mutateAsync({ + path, + indexer_rules_ids: [] + }); + })} open={open} setOpen={setOpen} title="Add Location URL" description="As you are using the browser version of Spacedrive you will (for now) need to specify an absolute URL of a directory local to the remote node." - ctaAction={() => - createLocation.mutate({ - path: locationUrl, - indexer_rules_ids: [] - } as LocationCreateArgs) - } loading={createLocation.isLoading} - submitDisabled={!locationUrl} ctaLabel="Add" trigger={null} > setLocationUrl(e.target.value)} required + {...form.register('path')} /> ); diff --git a/packages/interface/src/components/dialog/AlertDialog.tsx b/packages/interface/src/components/dialog/AlertDialog.tsx index d84e4d02f..094da0788 100644 --- a/packages/interface/src/components/dialog/AlertDialog.tsx +++ b/packages/interface/src/components/dialog/AlertDialog.tsx @@ -1,6 +1,8 @@ import { Button, Dialog, Input } from '@sd/ui'; import { Clipboard } from 'phosphor-react'; +import { useZodForm, z } from '@sd/ui/src/forms'; + export const GenericAlertDialogState = { open: false, title: '', @@ -28,16 +30,18 @@ export interface AlertDialogProps { } export const AlertDialog = (props: AlertDialogProps) => { + const form = useZodForm({ schema: z.object({}) }); // maybe a copy-to-clipboard button would be beneficial too return ( { + props.setOpen(false); + })} open={props.open} setOpen={props.setOpen} title={props.title} description={props.description} - ctaAction={() => { - props.setOpen(false); - }} ctaLabel={props.label !== undefined ? props.label : 'Done'} > {props.inputBox && ( diff --git a/packages/interface/src/components/dialog/BackupRestoreDialog.tsx b/packages/interface/src/components/dialog/BackupRestoreDialog.tsx index f0d947053..ebcf9fee5 100644 --- a/packages/interface/src/components/dialog/BackupRestoreDialog.tsx +++ b/packages/interface/src/components/dialog/BackupRestoreDialog.tsx @@ -1,17 +1,19 @@ import { useLibraryMutation } from '@sd/client'; -import { Button, Dialog, Input } from '@sd/ui'; +import { Button, Dialog } from '@sd/ui'; +import { forms } from '@sd/ui'; import { Eye, EyeSlash } from 'phosphor-react'; import { ReactNode, useState } from 'react'; -import { useForm } from 'react-hook-form'; import { usePlatform } from '../../util/Platform'; import { GenericAlertDialogProps } from './AlertDialog'; -type FormValues = { - masterPassword: string; - secretKey: string; - filePath: string; -}; +const { Input, useZodForm, z } = forms; + +const schema = z.object({ + masterPassword: z.string(), + secretKey: z.string(), + filePath: z.string() +}); export interface BackupRestorationDialogProps { trigger: ReactNode; @@ -53,12 +55,8 @@ export const BackupRestoreDialog = (props: BackupRestorationDialogProps) => { const MPCurrentEyeIcon = show.masterPassword ? EyeSlash : Eye; const SKCurrentEyeIcon = show.secretKey ? EyeSlash : Eye; - const form = useForm({ - defaultValues: { - masterPassword: '', - secretKey: '', - filePath: '' - } + const form = useZodForm({ + schema }); const onSubmit = form.handleSubmit((data) => { @@ -75,75 +73,73 @@ export const BackupRestoreDialog = (props: BackupRestorationDialogProps) => { }); return ( -
- setShow((old) => ({ ...old, backupRestoreDialog: e }))} - title="Restore Keys" - description="Restore keys from a backup." - loading={restoreKeystoreMutation.isLoading} - ctaLabel="Restore" - trigger={props.trigger} - > -
- - -
-
- - -
-
- +
+
+ + +
+
+ -
-
-
+ return; + } + platform.openFilePickerDialog().then((result) => { + if (result) form.setValue('filePath', result as string); + }); + }} + > + Select File + + +
); }; diff --git a/packages/interface/src/components/dialog/CreateLibraryDialog.tsx b/packages/interface/src/components/dialog/CreateLibraryDialog.tsx index 12374c0c0..d68e6a3a1 100644 --- a/packages/interface/src/components/dialog/CreateLibraryDialog.tsx +++ b/packages/interface/src/components/dialog/CreateLibraryDialog.tsx @@ -1,29 +1,39 @@ import { Algorithm, useBridgeMutation } from '@sd/client'; -import { Button, Dialog, Input, Select, SelectOption } from '@sd/ui'; +import { Button, Dialog, Select, SelectOption } from '@sd/ui'; +import { forms } from '@sd/ui'; import { useQueryClient } from '@tanstack/react-query'; import cryptoRandomString from 'crypto-random-string'; import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react'; import { PropsWithChildren, useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { getHashingAlgorithmSettings } from '~/screens/settings/library/KeysSetting'; -import { getHashingAlgorithmSettings } from '../../screens/settings/library/KeysSetting'; import { generatePassword } from '../key/KeyMounter'; import { PasswordMeter } from '../key/PasswordMeter'; -export default function CreateLibraryDialog({ - children, - onSubmit, - open, - setOpen -}: PropsWithChildren<{ onSubmit?: () => void; open: boolean; setOpen: (state: boolean) => void }>) { +const { Input, z, useZodForm } = forms; + +const schema = z.object({ + name: z.string(), + password: z.string(), + password_validate: z.string(), + secret_key: z.string(), + algorithm: z.string(), + hashing_algorithm: z.string() +}); + +interface Props { + onSubmit?: () => void; + open: boolean; + setOpen: (state: boolean) => void; +} + +export default function CreateLibraryDialog(props: PropsWithChildren) { const queryClient = useQueryClient(); - const form = useForm({ + const form = useZodForm({ + schema, defaultValues: { - name: '', - password: '' as string, - password_validate: '' as string, - secret_key: '' as string | null, + password: '', algorithm: 'XChaCha20Poly1305', hashing_algorithm: 'Argon2id-s' } @@ -43,23 +53,21 @@ export default function CreateLibraryDialog({ library ]); - if (onSubmit) onSubmit(); - setOpen(false); + props.onSubmit?.(); + props.setOpen(false); + form.reset(); }, onError: (err: any) => { console.error(err); } }); - const doSubmit = form.handleSubmit((data) => { - if (data.secret_key === '') { - data.secret_key = null; - } + const _onSubmit = form.handleSubmit(async (data) => { if (data.password !== data.password_validate) { alert('Passwords are not the same'); } else { - return createLibrary.mutateAsync({ + await createLibrary.mutateAsync({ ...data, algorithm: data.algorithm as Algorithm, hashing_algorithm: getHashingAlgorithmSettings(data.hashing_algorithm) @@ -69,170 +77,159 @@ export default function CreateLibraryDialog({ return ( -
-
-

Library name

+
+

Library name

+ +
+ + {/* TODO: Proper UI for this. Maybe checkbox for encrypted or not and then reveal these fields. Select encrypted by default. */} + {/* Make the secret key field empty to skip key setup. */} + +
+

Key Manager

+
+ +

Master password

+
-
+ + + +
+
+
+

Master password (again)

+
+ + +
+
+
+

Key secret (optional)

+
+ + + + +
+
-

Master password

-
- - - - -
+
+
+ Encryption +
-
-

Master password (again)

-
- - -
-
-
-

Key secret (optional)

-
- - - - -
+
+ Hashing +
+
-
-
- Encryption - -
-
- Hashing - -
-
- - - +
); } diff --git a/packages/interface/src/components/dialog/DecryptFileDialog.tsx b/packages/interface/src/components/dialog/DecryptFileDialog.tsx index ea9d7365d..fdb921aac 100644 --- a/packages/interface/src/components/dialog/DecryptFileDialog.tsx +++ b/packages/interface/src/components/dialog/DecryptFileDialog.tsx @@ -1,28 +1,29 @@ import { RadioGroup } from '@headlessui/react'; import { useLibraryMutation, useLibraryQuery } from '@sd/client'; -import { Button, Dialog, Input, Switch } from '@sd/ui'; +import { Button, Dialog } from '@sd/ui'; import { Eye, EyeSlash, Info } from 'phosphor-react'; import { useState } from 'react'; -import { useForm } from 'react-hook-form'; import { usePlatform } from '../../util/Platform'; import { Tooltip } from '../tooltip/Tooltip'; import { GenericAlertDialogProps } from './AlertDialog'; +import { Input, Switch, useZodForm, z } from '@sd/ui/src/forms'; + interface DecryptDialogProps { open: boolean; setOpen: (isShowing: boolean) => void; - location_id: number | null; - path_id: number | undefined; + location_id: number; + path_id: number; setAlertDialogData: (data: GenericAlertDialogProps) => void; } -type FormValues = { - type: 'password' | 'key'; - outputPath: string; - password: string; - saveToKeyManager: boolean; -}; +const schema = z.object({ + type: z.union([z.literal('password'), z.literal('key')]), + outputPath: z.string(), + password: z.string(), + saveToKeyManager: z.boolean() +}); export const DecryptFileDialog = (props: DecryptDialogProps) => { const platform = usePlatform(); @@ -67,11 +68,10 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => { const PasswordCurrentEyeIcon = show.password ? EyeSlash : Eye; - const form = useForm({ + const form = useZodForm({ + schema, defaultValues: { type: hasMountedKeys ? 'key' : 'password', - outputPath: '', - password: '', saveToKeyManager: true } }); @@ -83,127 +83,123 @@ export const DecryptFileDialog = (props: DecryptDialogProps) => { props.setOpen(false); - props.location_id && - props.path_id && - decryptFile.mutate({ - location_id: props.location_id, - path_id: props.path_id, - output_path: output, - password: pw, - save_to_library: save - }); + decryptFile.mutate({ + location_id: props.location_id, + path_id: props.path_id, + output_path: output, + password: pw, + save_to_library: save + }); form.reset(); }); return ( -
- + form.setValue('type', e)} + className="mt-2" > - form.setValue('type', e)} - className="mt-2" - > - Key Type -
- - {({ checked }) => ( - - )} - - - {({ checked }) => ( - - )} - -
-
- - {form.watch('type') === 'password' && ( - <> -
- + Key Type +
+ + {({ checked }) => ( -
- -
-
- form.setValue('saveToKeyManager', e)} - /> -
- Save to Key Manager - - - -
- - )} - -
-
- Output file + )} + + + {({ checked }) => ( + + )} + +
+ + {form.watch('type') === 'password' && ( + <> +
+
+ +
+
+ +
+ Save to Key Manager + + + +
+ + )} + +
+
+ Output file + +
-
-
+ + ); }; diff --git a/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx b/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx index cdc0363aa..59188b446 100644 --- a/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx +++ b/packages/interface/src/components/dialog/DeleteLibraryDialog.tsx @@ -1,34 +1,40 @@ import { useBridgeMutation } from '@sd/client'; import { Dialog } from '@sd/ui'; +import { forms } from '@sd/ui'; import { useQueryClient } from '@tanstack/react-query'; import { PropsWithChildren, useState } from 'react'; -export default function DeleteLibraryDialog( - props: PropsWithChildren<{ - libraryUuid: string; - }> -) { +const { useZodForm, z } = forms; + +interface Props { + libraryUuid: string; +} + +export default function DeleteLibraryDialog(props: PropsWithChildren) { const [openDeleteModal, setOpenDeleteModal] = useState(false); const queryClient = useQueryClient(); - const { mutate: deleteLib, isLoading: libDeletePending } = useBridgeMutation('library.delete', { + const deleteLib = useBridgeMutation('library.delete', { onSuccess: () => { setOpenDeleteModal(false); queryClient.invalidateQueries(['library.list']); } }); + const form = useZodForm({ schema: z.object({}) }); + return ( { + await deleteLib.mutateAsync(props.libraryUuid); + }} open={openDeleteModal} setOpen={setOpenDeleteModal} title="Delete Library" description="Deleting a library will permanently the database, the files themselves will not be deleted." - ctaAction={() => { - deleteLib(props.libraryUuid); - }} - loading={libDeletePending} + loading={deleteLib.isLoading} ctaDanger ctaLabel="Delete" trigger={props.children} diff --git a/packages/interface/src/components/dialog/EncryptFileDialog.tsx b/packages/interface/src/components/dialog/EncryptFileDialog.tsx index ea424e752..db90000ed 100644 --- a/packages/interface/src/components/dialog/EncryptFileDialog.tsx +++ b/packages/interface/src/components/dialog/EncryptFileDialog.tsx @@ -1,14 +1,13 @@ import { Algorithm, useLibraryMutation, useLibraryQuery } from '@sd/client'; import { Button, Dialog, Select, SelectOption } from '@sd/ui'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; +import { getHashingAlgorithmString } from '~/screens/settings/library/KeysSetting'; +import { usePlatform } from '~/util/Platform'; -import { getHashingAlgorithmString } from '../../screens/settings/library/KeysSetting'; -import { usePlatform } from '../../util/Platform'; import { SelectOptionKeyList } from '../key/KeyList'; -import { Checkbox } from '../primitive/Checkbox'; import { GenericAlertDialogProps } from './AlertDialog'; +import { CheckBox, useZodForm, z } from '@sd/ui/src/forms'; + interface EncryptDialogProps { open: boolean; setOpen: (isShowing: boolean) => void; @@ -17,14 +16,14 @@ interface EncryptDialogProps { setAlertDialogData: (data: GenericAlertDialogProps) => void; } -type FormValues = { - key: string; - encryptionAlgo: string; - hashingAlgo: string; - metadata: boolean; - previewMedia: boolean; - outputPath: string; -}; +const schema = z.object({ + key: z.string(), + encryptionAlgo: z.string(), + hashingAlgo: z.string(), + metadata: z.boolean(), + previewMedia: z.boolean(), + outputPath: z.string() +}); export const EncryptFileDialog = (props: EncryptDialogProps) => { const platform = usePlatform(); @@ -66,19 +65,11 @@ export const EncryptFileDialog = (props: EncryptDialogProps) => { } }); - const form = useForm({ - defaultValues: { - key: '', - encryptionAlgo: 'XChaCha20Poly1305', - hashingAlgo: 'Argon2id-s', - metadata: false, - previewMedia: false, - outputPath: '' - } + const form = useZodForm({ + schema }); const onSubmit = form.handleSubmit((data) => { - const output = data.outputPath !== '' ? data.outputPath : null; props.setOpen(false); props.location_id && @@ -90,113 +81,107 @@ export const EncryptFileDialog = (props: EncryptDialogProps) => { path_id: props.path_id, metadata: data.metadata, preview_media: data.previewMedia, - output_path: output + output_path: data.outputPath || null }); form.reset(); }); return ( -
- -
-
- Key - -
-
- Output file + +
+
+ Key + +
+
+ Output file - -
-
+ return; + } -
-
- Encryption - -
-
- Hashing - -
+ platform.saveFilePickerDialog().then((result) => { + if (result) form.setValue('outputPath', result as string); + }); + }} + > + Select +
+
-
-
- Metadata - form.setValue('metadata', e.target.checked)} - /> -
-
- Preview Media - form.setValue('previewMedia', e.target.checked)} - /> -
+
+
+ Encryption +
-
-
+
+ Hashing + +
+ + +
+
+ Metadata + +
+
+ Preview Media + +
+
+
); }; diff --git a/packages/interface/src/components/dialog/KeyViewerDialog.tsx b/packages/interface/src/components/dialog/KeyViewerDialog.tsx index 022183dd8..fa166a787 100644 --- a/packages/interface/src/components/dialog/KeyViewerDialog.tsx +++ b/packages/interface/src/components/dialog/KeyViewerDialog.tsx @@ -7,6 +7,8 @@ import { ReactNode, useState } from 'react'; import { getHashingAlgorithmString } from '../../screens/settings/library/KeysSetting'; import { SelectOptionKeyList } from '../key/KeyList'; +import { useZodForm, z } from '@sd/ui/src/forms'; + interface KeyViewerDialogProps { trigger: ReactNode; } @@ -34,7 +36,11 @@ export const KeyUpdater = (props: { return <>; }; +const schema = z.object({}); + export const KeyViewerDialog = (props: KeyViewerDialogProps) => { + const form = useZodForm({ schema }); + const keys = useLibraryQuery(['keys.list'], { onSuccess: (data) => { if (key === '' && data.length !== 0) { @@ -51,102 +57,101 @@ export const KeyViewerDialog = (props: KeyViewerDialogProps) => { const [hashingAlgo, setHashingAlgo] = useState(''); return ( - <> - { - setShowKeyViewerDialog(false); - }} - > - + { + setShowKeyViewerDialog(false); + }} + open={showKeyViewerDialog} + setOpen={setShowKeyViewerDialog} + trigger={props.trigger} + title="View Key Values" + description="Here you can view the values of your keys." + ctaLabel="Done" + > + -
-
- Key - { + setKey(e); + }} + > + {keys.data && key.uuid)} />} + +
+
+
+
+ Encryption + +
+
+ Hashing + +
+
+
+
+ Content Salt (hex) +
+ +
-
-
- Encryption - +
-
- Hashing - + +
-
-
- Content Salt (hex) -
- - -
-
-
-
-
- Key Value -
- - -
-
-
-
- + +
); }; diff --git a/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx b/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx index 1bbae31f3..ea038dca4 100644 --- a/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx +++ b/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx @@ -3,25 +3,26 @@ import { Button, Dialog, Input, Select, SelectOption } from '@sd/ui'; import cryptoRandomString from 'crypto-random-string'; import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react'; import { ReactNode, useState } from 'react'; -import { useForm } from 'react-hook-form'; import { getHashingAlgorithmSettings } from '../../screens/settings/library/KeysSetting'; import { generatePassword } from '../key/KeyMounter'; import { PasswordMeter } from '../key/PasswordMeter'; import { GenericAlertDialogProps } from './AlertDialog'; +import { useZodForm, z } from '@sd/ui/src/forms'; + export interface MasterPasswordChangeDialogProps { trigger: ReactNode; setAlertDialogData: (data: GenericAlertDialogProps) => void; } -type FormValues = { - masterPassword: string; - masterPassword2: string; - secretKey: string | null; - encryptionAlgo: string; - hashingAlgo: string; -}; +const schema = z.object({ + masterPassword: z.string(), + masterPassword2: z.string(), + secretKey: z.string().nullable(), + encryptionAlgo: z.string(), + hashingAlgo: z.string() +}); export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProps) => { const changeMasterPassword = useLibraryMutation('keys.changeMasterPassword', { @@ -59,11 +60,9 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp const MP2CurrentEyeIcon = show.masterPassword2 ? EyeSlash : Eye; const SKCurrentEyeIcon = show.secretKey ? EyeSlash : Eye; - const form = useForm({ + const form = useZodForm({ + schema, defaultValues: { - masterPassword: '', - masterPassword2: '', - secretKey: '', encryptionAlgo: 'XChaCha20Poly1305', hashingAlgo: 'Argon2id-s' } @@ -94,151 +93,148 @@ export const MasterPasswordChangeDialog = (props: MasterPasswordChangeDialogProp }); return ( -
- { - setShow((old) => ({ ...old, masterPasswordDialog: e })); - }} - title="Change Master Password" - description="Select a new master password for your key manager. Leave the key secret blank to disable it." - ctaDanger={true} - loading={changeMasterPassword.isLoading} - ctaLabel="Change" - trigger={props.trigger} - > -
- - - - -
-
- - -
+ { + setShow((old) => ({ ...old, masterPasswordDialog: e })); + }} + title="Change Master Password" + description="Select a new master password for your key manager. Leave the key secret blank to disable it." + ctaDanger={true} + loading={changeMasterPassword.isLoading} + ctaLabel="Change" + trigger={props.trigger} + > +
+ + + + +
+
+ + +
-
- - - - -
+
+ + + + +
- + -
-
- Encryption - -
-
- Hashing - -
+
+
+ Encryption +
-
- +
+ Hashing + +
+ +
); }; diff --git a/packages/interface/src/components/explorer/Explorer.tsx b/packages/interface/src/components/explorer/Explorer.tsx index d3e8a0dcc..841848498 100644 --- a/packages/interface/src/components/explorer/Explorer.tsx +++ b/packages/interface/src/components/explorer/Explorer.tsx @@ -41,6 +41,8 @@ export default function Explorer(props: Props) { } }); + const selectedItem = props.data?.items[expStore.selectedRowIndex]; + return ( <>
@@ -97,19 +99,19 @@ export default function Explorer(props: Props) { value={alertDialogData.value} inputBox={alertDialogData.inputBox} /> - {props.data && props.data.items[expStore.selectedRowIndex] && ( + {selectedItem && ( )} - {props.data && props.data.items[expStore.selectedRowIndex] && ( + {selectedItem && expStore.locationId !== null && ( ((props, ref) => { - return ( - - ); -}); +const TopBarButton = forwardRef( + ({ active, rounding, className, ...props }, ref) => { + return ( + + ); + } +); const SearchBar = forwardRef((props, forwardedRef) => { const { diff --git a/packages/interface/src/components/location/LocationListItem.tsx b/packages/interface/src/components/location/LocationListItem.tsx index 4fc374d66..2030cdc3c 100644 --- a/packages/interface/src/components/location/LocationListItem.tsx +++ b/packages/interface/src/components/location/LocationListItem.tsx @@ -7,6 +7,8 @@ import { useState } from 'react'; import { Folder } from '../icons/Folder'; +import { useZodForm, z } from '@sd/ui/src/forms'; + interface LocationListItemProps { location: Location & { node: Node }; } @@ -26,6 +28,8 @@ export default function LocationListItem({ location }: LocationListItemProps) { } ); + const form = useZodForm({ schema: z.object({}) }); + if (hide) return <>; return ( @@ -53,13 +57,14 @@ export default function LocationListItem({ location }: LocationListItemProps) { { + deleteLoc(location.id); + })} open={open} setOpen={setOpen} title="Delete Location" description="Deleting a location will also remove all files associated with it from the Spacedrive database, the files themselves will not be deleted." - ctaAction={() => { - deleteLoc(location.id); - }} loading={locDeletePending} ctaDanger ctaLabel="Delete" diff --git a/packages/interface/src/components/onboarding/Onboarding.tsx b/packages/interface/src/components/onboarding/Onboarding.tsx index 5796bd560..9f9a79a7c 100644 --- a/packages/interface/src/components/onboarding/Onboarding.tsx +++ b/packages/interface/src/components/onboarding/Onboarding.tsx @@ -1,7 +1,7 @@ +import { Button } from '@sd/ui'; import { useState } from 'react'; import { useNavigate } from 'react-router'; -import { Button } from '@sd/ui'; import CreateLibraryDialog from '../dialog/CreateLibraryDialog'; // TODO: This page requires styling for now it is just a placeholder. diff --git a/packages/interface/src/components/primitive/Checkbox.tsx b/packages/interface/src/components/primitive/Checkbox.tsx deleted file mode 100644 index 0a15de484..000000000 --- a/packages/interface/src/components/primitive/Checkbox.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import clsx from 'clsx'; - -export interface CheckboxProps extends React.InputHTMLAttributes { - primaryColor?: string; -} - -export const Checkbox: React.FC = (props) => { - return ( - - ); -}; diff --git a/packages/interface/src/components/primitive/PopoverPicker.tsx b/packages/interface/src/components/primitive/PopoverPicker.tsx index 8062705a0..a6e6719fd 100644 --- a/packages/interface/src/components/primitive/PopoverPicker.tsx +++ b/packages/interface/src/components/primitive/PopoverPicker.tsx @@ -1,16 +1,16 @@ import clsx from 'clsx'; import { useCallback, useRef, useState } from 'react'; import { HexColorPicker } from 'react-colorful'; +import { UseControllerProps, useController } from 'react-hook-form'; import useClickOutside from '../../hooks/useClickOutside'; -interface PopoverPickerProps { - value: string; - onChange: (color: string) => void; +interface PopoverPickerProps extends UseControllerProps { className?: string; } -export const PopoverPicker = ({ value, onChange, className }: PopoverPickerProps) => { +export const PopoverPicker = ({ className, ...props }: PopoverPickerProps) => { + const { field } = useController(props); const popover = useRef(null); const [isOpen, toggle] = useState(false); @@ -21,7 +21,7 @@ export const PopoverPicker = ({ value, onChange, className }: PopoverPickerProps
toggle(true)} /> {/* Pick Color */} @@ -32,7 +32,7 @@ export const PopoverPicker = ({ value, onChange, className }: PopoverPickerProps className="absolute left-0 rounded-md shadow" ref={popover} > - +
)}
diff --git a/packages/interface/src/screens/settings/library/TagsSettings.tsx b/packages/interface/src/screens/settings/library/TagsSettings.tsx index 3bb3f87a4..6439eab5c 100644 --- a/packages/interface/src/screens/settings/library/TagsSettings.tsx +++ b/packages/interface/src/screens/settings/library/TagsSettings.tsx @@ -1,65 +1,71 @@ import { Tag, useLibraryMutation, useLibraryQuery } from '@sd/client'; -import { TagUpdateArgs } from '@sd/client'; -import { Button, Card, Dialog, Input, Switch } from '@sd/ui'; +import { Button, Card, Dialog, Switch } from '@sd/ui'; import clsx from 'clsx'; import { Trash } from 'phosphor-react'; import { useCallback, useEffect, useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; import { useDebounce } from 'rooks'; - import { InputContainer } from '~/components/primitive/InputContainer'; import { PopoverPicker } from '~/components/primitive/PopoverPicker'; import { SettingsContainer } from '~/components/settings/SettingsContainer'; import { SettingsHeader } from '~/components/settings/SettingsHeader'; +import { Form, Input, useZodForm, z } from '@sd/ui/src/forms'; + export default function TagsSettings() { const [openCreateModal, setOpenCreateModal] = useState(false); const [openDeleteModal, setOpenDeleteModal] = useState(false); - // creating new tag state - const [newColor, setNewColor] = useState('#A717D9'); - const [newName, setNewName] = useState(''); const { data: tags } = useLibraryQuery(['tags.list']); const [selectedTag, setSelectedTag] = useState(tags?.[0] ?? null); - const { mutate: createTag, isLoading } = useLibraryMutation('tags.create', { + const createTag = useLibraryMutation('tags.create', { onError: (e) => { console.error('error', e); - }, - onSuccess: (_) => { - setOpenCreateModal(false); } }); - const updateTag = useLibraryMutation('tags.update'); - const deleteTag = useLibraryMutation('tags.delete', { onSuccess: () => { setSelectedTag(null); } }); - const { register, handleSubmit, watch, reset, control } = useForm({ - defaultValues: selectedTag as TagUpdateArgs + const createForm = useZodForm({ + schema: z.object({ + name: z.string(), + color: z.string() + }), + defaultValues: { + color: '#A717D9' + } }); + const updateForm = useZodForm({ + schema: z.object({ + id: z.number(), + name: z.string().nullable(), + color: z.string().nullable() + }), + defaultValues: selectedTag ?? undefined + }); + const deleteForm = useZodForm({ schema: z.object({}) }); + + const submitTagUpdate = updateForm.handleSubmit((data) => updateTag.mutateAsync(data)); + // eslint-disable-next-line react-hooks/exhaustive-deps + const autoUpdateTag = useCallback(useDebounce(submitTagUpdate, 500), [submitTagUpdate]); const setTag = useCallback( (tag: Tag | null) => { - if (tag) reset(tag); + if (tag) updateForm.reset(tag); setSelectedTag(tag); }, - [setSelectedTag, reset] + [setSelectedTag, updateForm] ); - const submitTagUpdate = handleSubmit((data) => updateTag.mutate(data)); - // eslint-disable-next-line react-hooks/exhaustive-deps - const autoUpdateTag = useCallback(useDebounce(submitTagUpdate, 500), []); - useEffect(() => { - const subscription = watch(() => autoUpdateTag()); + const subscription = updateForm.watch(() => autoUpdateTag()); return () => subscription.unsubscribe(); - }); + }, [updateForm, autoUpdateTag]); return ( @@ -69,17 +75,16 @@ export default function TagsSettings() { rightArea={
{ + await createTag.mutateAsync(data); + setOpenCreateModal(false); + })} open={openCreateModal} setOpen={setOpenCreateModal} title="Create New Tag" description="Choose a name and color." - ctaAction={() => { - createTag({ - name: newName, - color: newColor - }); - }} - loading={isLoading} + loading={createTag.isLoading} ctaLabel="Create" trigger={ - - -
- - - - + /> @@ -94,12 +61,10 @@ export function Dialog({ open, setOpen: onOpenChange, ...props }: DialogProps) { className="z-50 fixed top-0 bottom-0 left-0 right-0 grid place-items-center !pointer-events-none" style={styles} > -
{ - e.preventDefault(); - if (props.ctaAction) props.ctaAction(); - }} >
@@ -111,7 +76,7 @@ export function Dialog({ open, setOpen: onOpenChange, ...props }: DialogProps) { {props.children}
- {props.loading && } + {form.formState.isSubmitting && }
- + diff --git a/packages/ui/src/Input.tsx b/packages/ui/src/Input.tsx index 902c8bbf5..eee2e9114 100644 --- a/packages/ui/src/Input.tsx +++ b/packages/ui/src/Input.tsx @@ -2,13 +2,13 @@ import { VariantProps, cva } from 'class-variance-authority'; import clsx from 'clsx'; import { PropsWithChildren, forwardRef } from 'react'; -export interface InputBaseProps extends VariantProps {} +export interface InputBaseProps extends VariantProps {} export type InputProps = InputBaseProps & React.InputHTMLAttributes; export type TextareaProps = InputBaseProps & React.TextareaHTMLAttributes; -const inputStyles = cva( +const styles = cva( [ 'px-3 py-1 text-sm rounded-md border leading-7', 'outline-none shadow-sm focus:ring-2 transition-all' @@ -33,19 +33,13 @@ const inputStyles = cva( ); export const Input = forwardRef( - ({ size, variant, ...props }, ref) => { - return ( - - ); - } + ({ variant, size, className, ...props }, ref) => ( + + ) ); export const TextArea = ({ size, variant, ...props }: TextareaProps) => { - return