From 31a87f6794e468ca2bebaa0e36ad45f6eb6b6eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20Vasconcellos?= Date: Sat, 22 Apr 2023 04:30:11 +0000 Subject: [PATCH] [ENG-384] Indexer rules editor (#723) * WIP Create Indexer Rule UI + Backend api fix * Complete IndexerEditor UI for creating new rules - WIP testing to ensure all rules are parsed correctly - Add utilities to satisfy the TypeScript typechecker - Introduce a utility function to facilitate extracting information from an RSPCError - Modify AddLocationDialog to utilize the aforementioned utility function * Validation and submit logic is now functional - Reorganize UI for improved user flow - Implement validation and error messaging, replace some bare inputs with form inputs, and fix styling issues - Resolve issues with post-processing of rules during submission - Wrap editable portion of component in a `FormProvider` due to the `Form` being in a `Portal` - Add specialized `ErrorMessage` component to `@sd/ui` library - Update `AddLocationDialog` and `FormField` to utilize the new `ErrorMessage` component * Fix submit not waiting for confirmation from backend - Reset form after submit - Disable form while submiting - Update form related dependencies * Implement deleting an indexer rule - Modify indexer rule api route to disallow deleting default rules * Fix form reset on error - Minor style adjustments --- apps/mobile/package.json | 4 +- core/src/api/locations.rs | 25 +- core/src/location/indexer/rules.rs | 11 +- core/src/util/seeder.rs | 4 + .../settings/library/locations/$id.tsx | 2 +- .../library/locations/AddLocationDialog.tsx | 61 +- .../library/locations/IndexerRuleEditor.tsx | 583 ++++++++++++++++-- interface/package.json | 7 +- packages/client/package.json | 3 +- packages/client/src/core.ts | 2 +- packages/client/src/rspc.ts | 21 +- packages/client/src/utils/index.ts | 18 + packages/ui/package.json | 5 +- packages/ui/src/Input.tsx | 12 +- packages/ui/src/forms/Form.tsx | 26 +- packages/ui/src/forms/FormField.tsx | 5 +- pnpm-lock.yaml | Bin 829972 -> 852220 bytes 17 files changed, 670 insertions(+), 119 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6f75770df..b66846944 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@gorhom/bottom-sheet": "^4.4.5", - "@hookform/resolvers": "^2.9.11", + "@hookform/resolvers": "^3.1.0", "@react-native-async-storage/async-storage": "~1.17.11", "@react-native-masked-view/masked-view": "0.2.8", "@react-navigation/bottom-tabs": "^6.5.7", @@ -43,7 +43,7 @@ "moti": "^0.24.2", "phosphor-react-native": "^1.1.2", "react": "18.2.0", - "react-hook-form": "^7.43.5", + "react-hook-form": "^7.43.9", "react-native": "0.71.3", "react-native-document-picker": "^8.1.1", "react-native-fs": "^2.20.0", diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index 52abf4480..1717e055b 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -269,6 +269,27 @@ fn mount_indexer_rule_routes() -> RouterBuilder { }) .library_mutation("delete", |t| { t(|_, indexer_rule_id: i32, library| async move { + let indexer_rule_db = library.db.indexer_rule(); + + if let Some(indexer_rule) = indexer_rule_db + .to_owned() + .find_unique(indexer_rule::id::equals(indexer_rule_id)) + .exec() + .await? + { + if indexer_rule.default { + return Err(rspc::Error::new( + ErrorCode::Forbidden, + format!("Indexer rule can't be deleted"), + )); + } + } else { + return Err(rspc::Error::new( + ErrorCode::NotFound, + format!("Indexer rule not found"), + )); + } + library .db .indexer_rules_in_location() @@ -278,9 +299,7 @@ fn mount_indexer_rule_routes() -> RouterBuilder { .exec() .await?; - library - .db - .indexer_rule() + indexer_rule_db .delete(indexer_rule::id::equals(indexer_rule_id)) .exec() .await?; diff --git a/core/src/location/indexer/rules.rs b/core/src/location/indexer/rules.rs index 9dd8146fc..d1776350f 100644 --- a/core/src/location/indexer/rules.rs +++ b/core/src/location/indexer/rules.rs @@ -24,23 +24,22 @@ use tokio::fs; pub struct IndexerRuleCreateArgs { pub kind: RuleKind, pub name: String, - pub parameters: Vec, + pub parameters: Vec, } impl IndexerRuleCreateArgs { pub async fn create(self, library: &Library) -> Result { let parameters = match self.kind { RuleKind::AcceptFilesByGlob | RuleKind::RejectFilesByGlob => rmp_serde::to_vec( - &serde_json::from_slice::>(&self.parameters)? + &self + .parameters .into_iter() - .map(|s| Glob::new(&s)) + .map(|s| Glob::new(s.as_str())) .collect::, _>>()?, )?, RuleKind::AcceptIfChildrenDirectoriesArePresent - | RuleKind::RejectIfChildrenDirectoriesArePresent => { - rmp_serde::to_vec(&serde_json::from_slice::>(&self.parameters)?)? - } + | RuleKind::RejectIfChildrenDirectoriesArePresent => rmp_serde::to_vec(&self.parameters)?, }; library diff --git a/core/src/util/seeder.rs b/core/src/util/seeder.rs index 7542cc415..2f4823141 100644 --- a/core/src/util/seeder.rs +++ b/core/src/util/seeder.rs @@ -31,6 +31,7 @@ pub async fn indexer_rules_seeder(client: &PrismaClient) -> Result<(), SeederErr ], // Globset, even on Windows, requires the use of / as a separator // https://github.com/github/gitignore/blob/main/Global/Windows.gitignore + // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file #[cfg(target_os = "windows")] vec![ // Windows thumbnail cache files @@ -43,6 +44,9 @@ pub async fn indexer_rules_seeder(client: &PrismaClient) -> Result<(), SeederErr "**/$RECYCLE.BIN", // Chkdsk recovery directory "**/FOUND.[0-9][0-9][0-9]", + // Reserved names + "**/{CON,PRN,AUX,NUL,COM0,COM1,COM2,COM3,COM4,COM5,COM6,COM7,COM8,COM9,LPT0,LPT1,LPT2,LPT3,LPT4,LPT5,LPT6,LPT7,LPT8,LPT9}", + "**/{CON,PRN,AUX,NUL,COM0,COM1,COM2,COM3,COM4,COM5,COM6,COM7,COM8,COM9,LPT0,LPT1,LPT2,LPT3,LPT4,LPT5,LPT6,LPT7,LPT8,LPT9}.*", // User special files "C:/Users/*/NTUSER.DAT*", "C:/Users/*/ntuser.dat*", diff --git a/interface/app/$libraryId/settings/library/locations/$id.tsx b/interface/app/$libraryId/settings/library/locations/$id.tsx index c4f9b79cf..f20874e1d 100644 --- a/interface/app/$libraryId/settings/library/locations/$id.tsx +++ b/interface/app/$libraryId/settings/library/locations/$id.tsx @@ -169,7 +169,7 @@ export const Component = () => { } + render={({ field }) => } control={form.control} /> diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx index a400cce5a..1ae0c0193 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx @@ -1,10 +1,8 @@ -import { ErrorMessage } from '@hookform/error-message'; -import { RSPCError } from '@rspc/client'; import { useEffect, useMemo, useState } from 'react'; import { Controller } from 'react-hook-form'; -import { useLibraryMutation, useLibraryQuery } from '@sd/client'; +import { extractInfoRSPCError, useLibraryMutation, useLibraryQuery } from '@sd/client'; import { Dialog, UseDialogProps, useDialog } from '@sd/ui'; -import { Input, useZodForm, z } from '@sd/ui/src/forms'; +import { ErrorMessage, Input, useZodForm, z } from '@sd/ui/src/forms'; import { showAlertDialog } from '~/components/AlertDialog'; import { Platform, usePlatform } from '~/util/Platform'; import { IndexerRuleEditor } from './IndexerRuleEditor'; @@ -64,7 +62,7 @@ export const AddLocationDialog = ({ path, ...dialogProps }: Props) => { }, [form, path, indexerRulesIds]); useEffect(() => { - // TODO: Instead of clearing the error on every change, we should just validate with backend again + // TODO: Instead of clearing the error on every change, the backend should suport a way to validate without committing const subscription = form.watch(() => { form.clearErrors(REMOTE_ERROR_FORM_FIELD); setRemoteError(null); @@ -100,33 +98,27 @@ export const AddLocationDialog = ({ path, ...dialogProps }: Props) => { } }); - const onLocationSubmitError = async (error: Error) => { - if ('cause' in error && error.cause instanceof RSPCError) { - // TODO: error.code property is not yet implemented in RSPCError - // https://github.com/oscartbeaumont/rspc/blob/60a4fa93187c20bc5cb565cc6ee30b2f0903840e/packages/client/src/interop/error.ts#L59 - // So we grab it from the shape for now - const { code } = error.cause.shape; - if (code !== 500) { - let { message } = error; + const onLocationSubmitError = (error: Error) => { + const rspcErrorInfo = extractInfoRSPCError(error); + if (rspcErrorInfo && rspcErrorInfo.code !== 500) { + let { message } = rspcErrorInfo; + if (rspcErrorInfo.code == 409 && isRemoteErrorFormMessage(message)) { + setRemoteError(message); + message = REMOTE_ERROR_FORM_MESSAGES[message]; - if (code == 409 && isRemoteErrorFormMessage(message)) { - setRemoteError(message); - message = REMOTE_ERROR_FORM_MESSAGES[message]; - - /** - * TODO: On NEED_RELINK, we should query the backend for - * the current location indexer_rules_ids, then update the checkboxes - * accordingly. However we don't have the location id at this point. - * Maybe backend could return the location id in the error? - */ - } - - form.reset({}, { keepValues: true, keepErrors: true, keepIsValid: true }); - form.setError(REMOTE_ERROR_FORM_FIELD, { type: 'remote', message: message }); - - // Throw error to prevent dialog from closing - throw error; + /** + * TODO: On NEED_RELINK, we should query the backend for + * the current location indexer_rules_ids, then update the checkboxes + * accordingly. However we don't have the location id at this point. + * Maybe backend could return the location id in the error? + */ } + + form.reset({}, { keepValues: true, keepErrors: true, keepIsValid: true }); + form.setError(REMOTE_ERROR_FORM_FIELD, { type: 'remote', message: message }); + + // Throw error to prevent dialog from closing + throw error; } showAlertDialog({ @@ -176,14 +168,7 @@ export const AddLocationDialog = ({ path, ...dialogProps }: Props) => { - ( - - {message} - - )} - /> + ); }; diff --git a/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor.tsx b/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor.tsx index 3ee193f1e..ac3c6efe6 100644 --- a/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor.tsx +++ b/interface/app/$libraryId/settings/library/locations/IndexerRuleEditor.tsx @@ -1,70 +1,549 @@ -// import { PlusSquare } from '@phosphor-icons/react'; import clsx from 'clsx'; -import { ControllerRenderProps, FieldPath } from 'react-hook-form'; -import { useLibraryQuery } from '@sd/client'; -import { Button, Card } from '@sd/ui'; +import { CaretRight, Info, Plus, Trash, X } from 'phosphor-react'; +import { ComponentProps, createRef, forwardRef, useEffect, useId, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Controller, ControllerRenderProps, FormProvider } from 'react-hook-form'; +import { + RuleKind, + UnionToTuple, + extractInfoRSPCError, + isKeyOf, + useLibraryMutation, + useLibraryQuery +} from '@sd/client'; +import { Button, Card, Divider, Input, Switch, Tabs, Tooltip, inputSizes } from '@sd/ui'; +import { ErrorMessage, Form, Input as FormInput, useZodForm, z } from '@sd/ui/src/forms'; +import { InfoPill } from '~/app/$libraryId/Explorer/Inspector'; +import { showAlertDialog } from '~/components/AlertDialog'; +import { useOperatingSystem } from '~/hooks/useOperatingSystem'; +import { usePlatform } from '~/util/Platform'; +import { openDirectoryPickerDialog } from './AddLocationDialog'; -interface FormFields { - indexerRulesIds: number[]; +// NOTE: This should be updated whenever RuleKind is changed +const ruleKinds: UnionToTuple = [ + 'AcceptFilesByGlob', + 'RejectFilesByGlob', + 'AcceptIfChildrenDirectoriesArePresent', + 'RejectIfChildrenDirectoriesArePresent' +]; + +interface RulesInputProps { + form: string; + onChange: ComponentProps<'input'>['onChange']; + className: string; + onInvalid: ComponentProps<'input'>['onInvalid']; } -type FieldType = ControllerRenderProps< - FormFields, - Exclude, `indexerRulesIds.${number}`> +const RuleTabsInput = { + Name: forwardRef((props, ref) => { + const os = useOperatingSystem(true); + return ( + :"/\\|?*\u0000-\u0031]*' : '[^/\0]+'} + placeholder="File/Directory name" + {...props} + /> + ); + }), + Extension: forwardRef((props, ref) => ( + + )), + Path: forwardRef(({ className, ...props }, ref) => { + const os = useOperatingSystem(true); + const platform = usePlatform(); + const isWeb = platform.platform === 'web'; + return ( + :"/|?*\u0000-\u0031]*' + : '[^\0]+' + } + readOnly={!isWeb} + className={clsx(className, isWeb || 'cursor-pointer')} + placeholder={ + 'Path (e.g., ' + + // TODO: The check here shouldn't be for which os the UI is running, but for which os the node is running + (os === 'windows' + ? 'C:\\Users\\john\\Downloads' + : os === 'macOS' + ? '/Users/clara/Pictures' + : '/home/emily/Documents') + + ')' + } + onClick={(e) => { + openDirectoryPickerDialog(platform) + .then((path) => { + if (path) (e.target as HTMLInputElement).value = path; + }) + .catch((error) => + showAlertDialog({ + title: 'Error', + value: String(error) + }) + ); + }} + {...props} + /> + ); + }), + Advanced: forwardRef((props, ref) => { + const os = useOperatingSystem(true); + return ( + :"\u0000-\u0031]*' : '[^\0]+' + } + placeholder="Glob (e.g., **/.git)" + {...props} + /> + ); + }) +}; + +type RuleType = keyof typeof RuleTabsInput; + +type ParametersFieldType = ControllerRenderProps< + { parameters: [RuleType, string][] }, + 'parameters' >; -export interface IndexerRuleEditorProps { +interface RuleTabsContentProps { + form: string; field: T; + value: RuleType; +} + +function RuleTabsContent({ + form, + value, + field, + ...props +}: RuleTabsContentProps) { + const [invalid, setInvalid] = useState(false); + const inputRef = createRef(); + const RuleInput = RuleTabsInput[value]; + + return ( + +
+ { + const input = e.target; + setInvalid(false); + + // Even if the input value is valid, without clearing the custom validity, the invalid state will remain + input.setCustomValidity(''); + + input.reportValidity(); + }} + onInvalid={(e) => { + // Required to prevent the browser from showing the default error message + (e.target as HTMLInputElement).setCustomValidity(' '); + setInvalid(true); + }} + className={clsx('mr-2 flex-1', invalid && '!ring-2 !ring-red-500')} + /> + +
+
+ ); +} + +type IndexerRuleIdFieldType = ControllerRenderProps< + { indexerRulesIds: number[] }, + 'indexerRulesIds' +>; + +export interface IndexerRuleEditorProps { + field?: T; editable?: boolean; } -export function IndexerRuleEditor({ +const ruleKindEnum = z.enum(ruleKinds); + +const newRuleSchema = z.object({ + kind: ruleKindEnum, + name: z.string().min(3), + parameters: z + .array(z.tuple([z.enum(Object.keys(RuleTabsInput) as UnionToTuple), z.string()])) + .nonempty() +}); + +const REMOTE_ERROR_FORM_FIELD = 'root.serverError'; + +const removeParameter = (field: T, index: number) => + field.onChange(field.value.slice(0, index).concat(field.value.slice(index + 1))); + +export function IndexerRuleEditor({ field, editable }: IndexerRuleEditorProps) { + const form = useZodForm({ + schema: newRuleSchema, + defaultValues: { name: '', kind: 'RejectFilesByGlob', parameters: [] } + }); + const formId = useId(); const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']); - const indexRules = listIndexerRules.data; - return ( - - {indexRules ? ( - indexRules.map((rule) => { - const { id, name } = rule; - const enabled = field.value.includes(id); - return ( - - ); + const deleteIndexerRule = useLibraryMutation(['locations.indexer_rules.delete']); + const createIndexerRules = useLibraryMutation(['locations.indexer_rules.create']); + const [currentTab, setCurrentTab] = useState('Name'); + const [isDeleting, setIsDeleting] = useState(false); + const [showCreateNewRule, setShowCreateNewRule] = useState(false); + + useEffect(() => { + // TODO: Instead of clearing the error on every change, the backend should suport a way to validate without committing + const subscription = form.watch(() => { + form.clearErrors(REMOTE_ERROR_FORM_FIELD); + }); + return () => subscription.unsubscribe(); + }, [form]); + + const onSubmit = form.handleSubmit(({ kind, name, parameters }) => + createIndexerRules + .mutateAsync({ + kind, + name, + parameters: parameters.flatMap(([kind, rule]) => { + switch (kind) { + case 'Name': + return `**/${rule}`; + case 'Extension': + // .tar should work for .tar.gz, .tar.bz2, etc. + return [`**/*${rule}`, `**/*${rule}.*`]; + default: + return rule; + } }) - ) : ( -

- {listIndexerRules.isError - ? 'Error while retriving indexer rules' - : 'No indexer rules available'} -

+ }) + .then(async () => { + await listIndexerRules.refetch(); + form.reset(); + }, onSubmitError) + ); + + const onSubmitError = (error: Error) => { + const rspcErrorInfo = extractInfoRSPCError(error); + if (rspcErrorInfo && rspcErrorInfo.code !== 500) { + form.reset({}, { keepValues: true, keepErrors: true, keepIsValid: true }); + form.setError(REMOTE_ERROR_FORM_FIELD, { + type: 'remote', + message: rspcErrorInfo.message + }); + } else { + showAlertDialog({ + title: 'Error', + value: String(error) || 'Failed to add location' + }); + } + }; + + const indexRules = listIndexerRules.data; + const { + formState: { isSubmitting: isFormSubmitting, errors: formErrors } + } = form; + return ( + <> + + {indexRules ? ( + indexRules.map((rule) => { + const value = field?.value ?? []; + const enabled = value.includes(rule.id); + return ( + + ); + }) + ) : ( +

+ {listIndexerRules.isError + ? 'Error while retriving indexer rules' + : 'No indexer rules available'} +

+ )} +
+ + { + // Portal is required for Form because this component can be inside another form element + createPortal( +
, + document.body + ) + } + + {editable && ( + +
+ + + {showCreateNewRule && ( +
+

Rules

+ + + + ( + <> +
+ {((rules) => + rules.length === 0 ? ( +

+ No rules yet +

+ ) : ( + rules.map(([kind, rule], index) => ( + + + {kind} + + +

+ {rule} +

+ +
+ + {/*

+ {kind} +

*/} + + + + )) + ))(form.getValues().parameters)} +
+ + + + + isKeyOf(tab, RuleTabsInput) && + setCurrentTab(tab) + } + > + + {Object.keys(RuleTabsInput).map((name) => ( + + {name} + + ))} + + + {...(Object.keys(RuleTabsInput) as RuleType[]).map( + (name) => ( + + ) + )} + + + )} + control={form.control} + /> + +

+ Settings +

+ + + +
+
+ + +
+ + + ( + { + // TODO: This rule kinds are broken right now in the backend and this UI doesn't make much sense for it + // kind.AcceptIfChildrenDirectoriesArePresent + // kind.RejectIfChildrenDirectoriesArePresent + const kind = ruleKindEnum.enum; + field.onChange( + checked + ? kind.AcceptFilesByGlob + : kind.RejectFilesByGlob + ); + }} + size="sm" + /> + )} + control={form.control} + /> +
+
+ + +
+ + +
+ )} +
+ )} - {/* {editable && ( - - )} */} - + ); } diff --git a/interface/package.json b/interface/package.json index d251b8c5f..012c36310 100644 --- a/interface/package.json +++ b/interface/package.json @@ -18,8 +18,6 @@ "dependencies": { "@fontsource/inter": "^4.5.13", "@headlessui/react": "^1.7.3", - "@hookform/error-message": "^2.0.1", - "@hookform/resolvers": "^2.9.10", "@radix-ui/react-progress": "^1.0.1", "@radix-ui/react-slider": "^1.1.0", "@radix-ui/react-toast": "^1.1.2", @@ -49,7 +47,7 @@ "react-colorful": "^5.6.1", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", - "react-hook-form": "^7.43.5", + "react-hook-form": "^7.43.9", "react-json-view": "^1.21.3", "react-loading-skeleton": "^3.1.0", "react-qr-code": "^2.0.11", @@ -60,8 +58,7 @@ "tailwindcss": "^3.1.8", "use-count-up": "^3.0.1", "use-debounce": "^8.0.4", - "valtio": "^1.7.4", - "zod": "^3.20.2" + "valtio": "^1.7.4" }, "devDependencies": { "@sd/config": "workspace:*", diff --git a/packages/client/package.json b/packages/client/package.json index 90f106529..3807253fc 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -22,7 +22,8 @@ "@zxcvbn-ts/language-common": "^2.0.1", "@zxcvbn-ts/language-en": "^2.1.0", "plausible-tracker": "^0.3.8", - "valtio": "^1.7.4" + "valtio": "^1.7.4", + "zod": "^3.21.4" }, "devDependencies": { "@types/react": "^18.0.21", diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 79659b05b..4df6ff23d 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -155,7 +155,7 @@ export type IndexerRule = { id: number, kind: number, name: string, default: boo * In case of `RuleKind::AcceptIfChildrenDirectoriesArePresent` or `RuleKind::RejectIfChildrenDirectoriesArePresent` the * `parameters` field must be a vector of strings containing the names of the directories. */ -export type IndexerRuleCreateArgs = { kind: RuleKind, name: string, parameters: number[] } +export type IndexerRuleCreateArgs = { kind: RuleKind, name: string, parameters: string[] } export type InvalidateOperationEvent = { key: string, arg: any, result: any | null } diff --git a/packages/client/src/rspc.ts b/packages/client/src/rspc.ts index 5488355da..4a9566a1a 100644 --- a/packages/client/src/rspc.ts +++ b/packages/client/src/rspc.ts @@ -1,4 +1,4 @@ -import { ProcedureDef } from '@rspc/client'; +import { ProcedureDef, RSPCError } from '@rspc/client'; import { internal_createReactHooksFactory } from '@rspc/react'; import { LibraryArgs, Procedures } from './core'; import { currentLibraryCache } from './hooks'; @@ -99,3 +99,22 @@ export function useInvalidateQuery() { } }); } + +export function extractInfoRSPCError(error: unknown) { + if ( + error == null || + typeof error !== 'object' || + !('cause' in error && error.cause instanceof RSPCError) + ) + return null; + + // TODO: error.code property is not yet implemented in RSPCError + // https://github.com/oscartbeaumont/rspc/blob/60a4fa93187c20bc5cb565cc6ee30b2f0903840e/packages/client/src/interop/error.ts#L59 + // So we grab it from the shape for now + const { code } = error.cause.shape; + + return { + code: Number.isInteger(code) ? code : 500, + message: 'message' in error ? String(error.message) : '' + }; +} diff --git a/packages/client/src/utils/index.ts b/packages/client/src/utils/index.ts index 571c44c0f..9e2aacb0e 100644 --- a/packages/client/src/utils/index.ts +++ b/packages/client/src/utils/index.ts @@ -19,3 +19,21 @@ export function arraysEqual(a: T[], b: T[]) { return a.every((n, i) => b[i] === n); } + +export function isKeyOf(key: PropertyKey, obj: T): key is keyof T { + return key in obj; +} + +// From: https://github.com/microsoft/TypeScript/issues/13298#issuecomment-885980381 +// Warning: Avoid using the types bellow as a generic parameter, as it tanks the typechecker performance +export type UnionToIntersection = (U extends never ? never : (arg: U) => never) extends ( + arg: infer I +) => void + ? I + : never; + +export type UnionToTuple = UnionToIntersection T> extends ( + _: never +) => infer W + ? [...UnionToTuple>, W] + : []; diff --git a/packages/ui/package.json b/packages/ui/package.json index cda086203..48711bb01 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,6 +21,8 @@ "dependencies": { "@headlessui/react": "^1.7.3", "@headlessui/tailwindcss": "^0.1.1", + "@hookform/error-message": "^2.0.1", + "@hookform/resolvers": "^3.1.0", "@radix-ui/react-checkbox": "^1.0.3", "@radix-ui/react-context-menu": "^1.0.0", "@radix-ui/react-dialog": "^1.0.0", @@ -42,7 +44,8 @@ "react-router-dom": "6.9.0", "react-spring": "^9.5.5", "tailwindcss-radix": "^2.6.0", - "use-debounce": "^9.0.4" + "use-debounce": "^9.0.4", + "zod": "^3.21.4" }, "devDependencies": { "@babel/core": "^7.19.3", diff --git a/packages/ui/src/Input.tsx b/packages/ui/src/Input.tsx index 2ba82bd81..d568dc664 100644 --- a/packages/ui/src/Input.tsx +++ b/packages/ui/src/Input.tsx @@ -14,6 +14,12 @@ export type InputProps = InputBaseProps & Omit, 's export type TextareaProps = InputBaseProps & React.ComponentProps<'textarea'>; +export const inputSizes = { + sm: 'h-[30px]', + md: 'h-[34px]', + lg: 'h-[38px]' +}; + export const inputStyles = cva( [ 'rounded-md border text-sm leading-7', @@ -30,11 +36,7 @@ export const inputStyles = cva( error: { true: 'border-red-500 focus-within:border-red-500 focus-within:ring-red-400/30' }, - size: { - sm: 'h-[30px]', - md: 'h-[34px]', - lg: 'h-[38px]' - } + size: inputSizes }, defaultVariants: { variant: 'default', diff --git a/packages/ui/src/forms/Form.tsx b/packages/ui/src/forms/Form.tsx index f454bfefb..79acc8125 100644 --- a/packages/ui/src/forms/Form.tsx +++ b/packages/ui/src/forms/Form.tsx @@ -1,4 +1,6 @@ +import { ErrorMessage as ErrorMessagePrimitive } from '@hookform/error-message'; import { zodResolver } from '@hookform/resolvers/zod'; +import { VariantProps, cva } from 'class-variance-authority'; import { ComponentProps } from 'react'; import { FieldValues, @@ -50,10 +52,32 @@ export const useZodForm = { const { schema, ...formProps } = props ?? {}; - return useForm({ + return useForm>({ ...formProps, resolver: zodResolver(schema || z.object({})) }); }; +export const errorStyles = cva('inline-block whitespace-pre-wrap text-red-500', { + variants: { + variant: { + none: '', + default: 'text-xs', + large: 'w-full text-center text-sm font-semibold' + } + }, + defaultVariants: { + variant: 'default' + } +}); + +export interface ErrorMessageProps extends VariantProps { + name: string; + className: string; +} + +export const ErrorMessage = ({ name, variant, className }: ErrorMessageProps) => ( + +); + export { z } from 'zod'; diff --git a/packages/ui/src/forms/FormField.tsx b/packages/ui/src/forms/FormField.tsx index 5d8371d33..007de99b4 100644 --- a/packages/ui/src/forms/FormField.tsx +++ b/packages/ui/src/forms/FormField.tsx @@ -1,5 +1,6 @@ import { PropsWithChildren, ReactNode, useId } from 'react'; import { useFormContext } from 'react-hook-form'; +import { ErrorMessage } from './Form'; export interface UseFormFieldProps extends PropsWithChildren { name: string; @@ -21,7 +22,7 @@ export const useFormField =

(props: P) => { interface FormFieldProps extends Omit { id: string; - error?: string; + name: string; label?: string | ReactNode; } @@ -34,7 +35,7 @@ export const FormField = (props: FormFieldProps) => { )} {props.children} - {props.error && {props.error}} +

); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61489c0339b9cf3f067748ea5fdfad7f70e963d0..efb984f5a7e1ce78cd690545d6e7f7a394701c8d 100644 GIT binary patch delta 14168 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 delta 7391 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 VQTAk|9xpul2xBS9I?MeC`Co^z7d-#~