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 61489c033..efb984f5a 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ