From 5502c167b7f3cc1ab988e60f161dd5fdbcd86b52 Mon Sep 17 00:00:00 2001 From: melvinchia3636 Date: Mon, 1 Jun 2026 19:44:16 +0800 Subject: [PATCH] feat(ui): form system overhaul --- apps/lifeforge--achievements | 1 + apps/lifeforge--wallet | 1 + bun.lock | 8 + packages/ui/package.json | 2 + .../FormModal/FormModal.stories.tsx | 172 ++++++ .../form/components/FormModal/index.tsx | 111 ++++ .../form/components/fields/CheckboxField.tsx | 68 +++ .../form/components/fields/ColorField.tsx | 33 ++ .../form/components/fields/CurrencyField.tsx | 33 ++ .../form/components/fields/DateField.tsx | 42 ++ .../form/components/fields/FileField.tsx | 41 ++ .../form/components/fields/IconField.tsx | 33 ++ .../form/components/fields/ListboxField.tsx | 199 +++++++ .../form/components/fields/LocationField.tsx | 44 ++ .../form/components/fields/NumberField.tsx | 33 ++ .../form/components/fields/RRuleField.tsx | 33 ++ .../form/components/fields/SliderField.tsx | 32 ++ .../form/components/fields/TextAreaField.tsx | 33 ++ .../form/components/fields/TextField.tsx | 62 ++ .../form/components/fields/index.ts | 25 + .../form/hooks/createDefaultValues.test.ts | 95 +++ .../form/hooks/createDefaultValues.ts | 70 +++ packages/ui/src/components/form/index.tsx | 5 + packages/ui/src/components/index.ts | 21 + .../components/inputs/ColorInput/index.tsx | 2 +- .../components/inputs/CurrencyInput/index.tsx | 2 +- .../src/components/inputs/DateInput/index.tsx | 2 +- .../inputs/FileInput/FileInput.stories.tsx | 45 +- .../FileInput/FilePickerModal/index.tsx | 30 +- .../components/CompactFileDisplay.tsx | 63 ++ .../FileInput/components/EmptyFileInput.tsx | 45 ++ .../components/PreviewFileDisplay.tsx | 37 ++ .../src/components/inputs/FileInput/index.tsx | 215 ++++--- .../src/components/inputs/IconInput/index.tsx | 2 +- .../ListboxInput/components/ListboxOption.tsx | 11 +- .../components/inputs/ListboxInput/index.tsx | 2 - .../components/inputs/NumberInput/index.tsx | 6 +- .../components/inputs/SliderInput/index.tsx | 2 +- .../components/inputs/TextAreaInput/index.tsx | 2 +- .../src/components/inputs/TextInput/index.tsx | 2 +- .../ConfirmationModal.stories.tsx | 0 .../ConfirmationModal/index.tsx | 0 .../ModalHeader.stories.tsx | 170 +++--- .../ModalHeader.tsx => ModalHeader/index.tsx} | 16 +- .../ModalManager/ModalManager.stories.tsx | 0 .../modals/{core => }/ModalManager/index.tsx | 2 +- .../ModalWrapper.css.ts | 0 .../index.tsx} | 0 .../{features => }/ViewImageModal/index.tsx | 2 +- .../features/FormModal/FormModal.stories.tsx | 394 ------------- .../components/FormCheckboxInput.tsx | 54 -- .../FormInputs/components/FormColorInput.tsx | 31 - .../components/FormCurrencyInput.tsx | 34 -- .../FormInputs/components/FormDateInput.tsx | 34 -- .../FormInputs/components/FormFileInput.tsx | 52 -- .../FormInputs/components/FormIconInput.tsx | 31 - .../components/FormListboxInput.tsx | 185 ------ .../components/FormLocationInput.tsx | 34 -- .../FormInputs/components/FormNumberInput.tsx | 33 -- .../FormInputs/components/FormRRuleInput.tsx | 25 - .../FormInputs/components/FormSliderInput.tsx | 36 -- .../components/FormTextAreaInput.tsx | 35 -- .../FormInputs/components/FormTextInput.tsx | 64 --- .../FormModal/components/FormInputs/index.tsx | 202 ------- .../FormModal/components/SubmitButton.tsx | 41 -- .../modals/features/FormModal/formBuilder.ts | 539 ------------------ .../modals/features/FormModal/index.tsx | 329 ----------- .../FormModal/typescript/form.types.ts | 224 -------- .../features/FormModal/utils/formUtils.ts | 226 -------- .../src/components/overlays/modals/index.ts | 14 +- packages/ui/src/index.ts | 28 +- 71 files changed, 1591 insertions(+), 2909 deletions(-) create mode 160000 apps/lifeforge--achievements create mode 160000 apps/lifeforge--wallet create mode 100644 packages/ui/src/components/form/components/FormModal/FormModal.stories.tsx create mode 100644 packages/ui/src/components/form/components/FormModal/index.tsx create mode 100644 packages/ui/src/components/form/components/fields/CheckboxField.tsx create mode 100644 packages/ui/src/components/form/components/fields/ColorField.tsx create mode 100644 packages/ui/src/components/form/components/fields/CurrencyField.tsx create mode 100644 packages/ui/src/components/form/components/fields/DateField.tsx create mode 100644 packages/ui/src/components/form/components/fields/FileField.tsx create mode 100644 packages/ui/src/components/form/components/fields/IconField.tsx create mode 100644 packages/ui/src/components/form/components/fields/ListboxField.tsx create mode 100644 packages/ui/src/components/form/components/fields/LocationField.tsx create mode 100644 packages/ui/src/components/form/components/fields/NumberField.tsx create mode 100644 packages/ui/src/components/form/components/fields/RRuleField.tsx create mode 100644 packages/ui/src/components/form/components/fields/SliderField.tsx create mode 100644 packages/ui/src/components/form/components/fields/TextAreaField.tsx create mode 100644 packages/ui/src/components/form/components/fields/TextField.tsx create mode 100644 packages/ui/src/components/form/components/fields/index.ts create mode 100644 packages/ui/src/components/form/hooks/createDefaultValues.test.ts create mode 100644 packages/ui/src/components/form/hooks/createDefaultValues.ts create mode 100644 packages/ui/src/components/form/index.tsx create mode 100644 packages/ui/src/components/index.ts create mode 100644 packages/ui/src/components/inputs/FileInput/components/CompactFileDisplay.tsx create mode 100644 packages/ui/src/components/inputs/FileInput/components/EmptyFileInput.tsx create mode 100644 packages/ui/src/components/inputs/FileInput/components/PreviewFileDisplay.tsx rename packages/ui/src/components/overlays/modals/{features => }/ConfirmationModal/ConfirmationModal.stories.tsx (100%) rename packages/ui/src/components/overlays/modals/{features => }/ConfirmationModal/index.tsx (100%) rename packages/ui/src/components/overlays/modals/{core/components => ModalHeader}/ModalHeader.stories.tsx (70%) rename packages/ui/src/components/overlays/modals/{core/components/ModalHeader.tsx => ModalHeader/index.tsx} (88%) rename packages/ui/src/components/overlays/modals/{core => }/ModalManager/ModalManager.stories.tsx (100%) rename packages/ui/src/components/overlays/modals/{core => }/ModalManager/index.tsx (95%) rename packages/ui/src/components/overlays/modals/{core/components => ModalWrapper}/ModalWrapper.css.ts (100%) rename packages/ui/src/components/overlays/modals/{core/components/ModalWrapper.tsx => ModalWrapper/index.tsx} (100%) rename packages/ui/src/components/overlays/modals/{features => }/ViewImageModal/index.tsx (88%) delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/FormModal.stories.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCheckboxInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormColorInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCurrencyInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormDateInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormFileInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormIconInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormListboxInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormLocationInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormNumberInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormRRuleInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormSliderInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextAreaInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextInput.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/index.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/components/SubmitButton.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/formBuilder.ts delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/index.tsx delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/typescript/form.types.ts delete mode 100644 packages/ui/src/components/overlays/modals/features/FormModal/utils/formUtils.ts diff --git a/apps/lifeforge--achievements b/apps/lifeforge--achievements new file mode 160000 index 000000000..8e8aaaf91 --- /dev/null +++ b/apps/lifeforge--achievements @@ -0,0 +1 @@ +Subproject commit 8e8aaaf918591088d3144f5205482a55c6f3c8e0 diff --git a/apps/lifeforge--wallet b/apps/lifeforge--wallet new file mode 160000 index 000000000..a4116d2fc --- /dev/null +++ b/apps/lifeforge--wallet @@ -0,0 +1 @@ +Subproject commit a4116d2fc6739d7fc38bc0dc262cb6ea45441cd0 diff --git a/bun.lock b/bun.lock index af062ad0b..5ab09e9b1 100644 --- a/bun.lock +++ b/bun.lock @@ -247,6 +247,7 @@ "version": "0.25.29-1", "dependencies": { "@headlessui/react": "^2.2.9", + "@hookform/resolvers": "^5.4.0", "@iconify/collections": "^1.0.690", "@iconify/react": "^6.0.2", "@iconify/tools": "^4.1.4", @@ -284,6 +285,7 @@ "react-datepicker": "^8.7.0", "react-dom": "^19.2.0", "react-dropzone": "^14.3.8", + "react-hook-form": "^7.77.0", "react-i18next": "^15.7.4", "react-medium-image-zoom": "^5.4.0", "react-otp-input": "^3.1.1", @@ -635,6 +637,8 @@ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hookform/resolvers": ["@hookform/resolvers@5.4.0", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], @@ -967,6 +971,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@storybook/addon-docs": ["@storybook/addon-docs@10.1.0", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.1.0", "@storybook/icons": "^2.0.0", "@storybook/react-dom-shim": "10.1.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.1.0" } }, "sha512-CVW2pc9iAfz1A6/L9S0z8XqKUON+u92xaOTC1x6d3WS8cyOT94nD7tfohT8aWydwvvmtwRHZJzl0aWnKUNgSJw=="], "@storybook/addon-onboarding": ["@storybook/addon-onboarding@10.1.0", "", { "peerDependencies": { "storybook": "^10.1.0" } }, "sha512-1ejlsj3gb2f2mVgTbLJyQbnF7e3iT5xUwIyFnynHIbdw8HkcQtF+Kt56HOkP27llGv2zAvpmXZ4tu1y461hzzA=="], @@ -3063,6 +3069,8 @@ "react-grid-layout": ["react-grid-layout@1.5.3", "", { "dependencies": { "clsx": "^2.1.1", "fast-equals": "^4.0.3", "prop-types": "^15.8.1", "react-draggable": "^4.4.6", "react-resizable": "^3.0.5", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g=="], + "react-hook-form": ["react-hook-form@7.77.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg=="], + "react-i18next": ["react-i18next@16.6.6", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.10.9", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index fac045f31..9040e13b3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@headlessui/react": "^2.2.9", + "@hookform/resolvers": "^5.4.0", "@iconify/collections": "^1.0.690", "@iconify/react": "^6.0.2", "@iconify/tools": "^4.1.4", @@ -66,6 +67,7 @@ "react-datepicker": "^8.7.0", "react-dom": "^19.2.0", "react-dropzone": "^14.3.8", + "react-hook-form": "^7.77.0", "react-i18next": "^15.7.4", "react-medium-image-zoom": "^5.4.0", "react-otp-input": "^3.1.1", diff --git a/packages/ui/src/components/form/components/FormModal/FormModal.stories.tsx b/packages/ui/src/components/form/components/FormModal/FormModal.stories.tsx new file mode 100644 index 000000000..52bd87a99 --- /dev/null +++ b/packages/ui/src/components/form/components/FormModal/FormModal.stories.tsx @@ -0,0 +1,172 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { useForm } from 'react-hook-form' +import z from 'zod' + +import { Button } from '@/components/inputs' +import { useModalStore } from '@/providers' + +import { FormModal } from '.' +import { createDefaultValues } from '../../hooks/createDefaultValues' +import { ColorField, ListboxField, NumberField, TextField } from '../fields' + +const meta = { + component: FormModal, + parameters: { + deepControls: { enabled: true } + } +} satisfies Meta + +export default meta + +type Story = StoryObj + +const cuteFormSchema = z + .object({ + age: z + .number() + .int('Invalid age. Age must be an integer.') + .nonnegative('Invalid age. Age must be positive.'), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color hex'), + confirmPassword: z.string(), + icon: z.enum(['tabler:star', 'tabler:check', 'tabler:x', 'tabler:heart']), + name: z.string().min(1, 'Name is required'), + password: z.string().min(8, 'Password must be at least 8 characters') + }) + .superRefine(function ({ confirmPassword, password }, ctx) { + if (confirmPassword !== password) { + ctx.addIssue({ + code: 'custom', + message: 'Password does not match', + path: ['confirmPassword'] + }) + } + }) + +function getIconOptions(name: string) { + return [ + ...(name === 'melvin' + ? [ + { + color: '#FF0000', + icon: 'tabler:heart', + text: 'Heart', + value: 'tabler:heart' + } + ] + : []), + { icon: 'tabler:star', text: 'Star', value: 'tabler:star' }, + { icon: 'tabler:check', text: 'Check', value: 'tabler:check' }, + { icon: 'tabler:x', text: 'X', value: 'tabler:x' } + ] +} + +function MyFormModal({ onClose }: { onClose: () => void }) { + const form = useForm({ + defaultValues: { + ...createDefaultValues(cuteFormSchema), + color: '#FFFFFF' + }, + resolver: zodResolver(cuteFormSchema) + }) + + const iconOptions = getIconOptions(form.watch('name')) + + return ( + + + + + + { + const randomPassword = Math.random().toString(36).slice(-8) + + form.setValue('password', randomPassword, { shouldValidate: true }) + form.setValue('confirmPassword', randomPassword, { + shouldValidate: true + }) + }, + variant: 'plain' + }} + control={form.control} + icon="tabler:lock" + label="Password" + name="password" + placeholder="••••••••" + /> + + + ) +} + +export const Default: Story = { + args: { + ui: { + icon: 'tabler:forms', + loading: false, + namespace: '', + onClose: function () {}, + submitButton: 'update', + title: 'Form Modal' + } + } as never, + render: function () { + const { open } = useModalStore() + + function handleButtonClick() { + open(MyFormModal, {}) + } + + return ( + + ) + } +} diff --git a/packages/ui/src/components/form/components/FormModal/index.tsx b/packages/ui/src/components/form/components/FormModal/index.tsx new file mode 100644 index 000000000..bfc241d3b --- /dev/null +++ b/packages/ui/src/components/form/components/FormModal/index.tsx @@ -0,0 +1,111 @@ +import { + type FieldValues, + type SubmitHandler, + type UseFormReturn +} from 'react-hook-form' +import { toast } from 'react-toastify' + +import { usePromiseLoading } from '@lifeforge/shared' + +import { LoadingScreen } from '@/components/feedback' +import { Button } from '@/components/inputs' +import { ModalHeader } from '@/components/overlays' +import { Flex, Stack } from '@/components/primitives' + +const SUBMISSION_CONFIG_TEMPLATE = { + create: { + label: 'Create', + icon: 'tabler:plus' + }, + update: { + label: 'Update', + icon: 'tabler:pencil' + } +} as const + +type SubmissionConfig = + | { + template: keyof typeof SUBMISSION_CONFIG_TEMPLATE + disabled?: boolean + handler: SubmitHandler + } + | { + label: string + icon: string + disabled?: boolean + handler: SubmitHandler + } + +export function FormModal({ + form, + uiConfig: { title, icon, namespace, loading = false, onClose, headerActions }, + submissionConfig, + children +}: { + form: UseFormReturn + uiConfig: { + title: string | React.ReactNode + icon: string + onClose: () => void + namespace?: string + loading?: boolean + headerActions?: React.ReactNode + } + submissionConfig: SubmissionConfig + children: React.ReactNode +}) { + const handleSubmit = form.handleSubmit(async values => { + try { + await submissionConfig.handler(values) + + onClose?.() + } catch (error) { + console.error(error) + toast.error('Failed to submit form. Please check the console for details') + } + }) + + const [submitButtonLoading, onSubmitButtonClick] = + usePromiseLoading(handleSubmit) + + const finalSubmissionConfig = + 'template' in submissionConfig + ? SUBMISSION_CONFIG_TEMPLATE[submissionConfig.template] + : submissionConfig + + return ( + + + {!loading ? ( + <> + {children} + + + ) : ( + + + + )} + + ) +} diff --git a/packages/ui/src/components/form/components/fields/CheckboxField.tsx b/packages/ui/src/components/form/components/fields/CheckboxField.tsx new file mode 100644 index 000000000..1319076c9 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/CheckboxField.tsx @@ -0,0 +1,68 @@ +import _ from 'lodash' +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { Switch } from '@/components/inputs' +import { Flex, Icon, Text } from '@/components/primitives' + +type CheckboxFieldProps = { + control: Control + name: FieldPathByValue + label: string + icon: string + disabled?: boolean + namespace?: string +} + +export function CheckboxField({ + control, + name, + label, + icon, + disabled = false, + namespace +}: CheckboxFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + const { t } = useTranslation(namespace) + + const labelText = t([ + ['inputs', _.camelCase(label), 'label'].filter(Boolean).join('.'), + ['inputs', _.camelCase(label)].filter(Boolean).join('.') + ]) + + function handleSwitchChange() { + field.onChange(!field.value) + } + + return ( + + + + + + {labelText} + + + + + {fieldState.error?.message && ( + + {fieldState.error.message} + + )} + + ) +} diff --git a/packages/ui/src/components/form/components/fields/ColorField.tsx b/packages/ui/src/components/form/components/fields/ColorField.tsx new file mode 100644 index 000000000..ce455bc77 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/ColorField.tsx @@ -0,0 +1,33 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { ColorInput, type ColorInputProps } from '@/components/inputs' + +type ColorFieldProps = { + control: Control + name: FieldPathByValue +} & Omit + +export function ColorField({ + control, + name, + ...rest +}: ColorFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/CurrencyField.tsx b/packages/ui/src/components/form/components/fields/CurrencyField.tsx new file mode 100644 index 000000000..0a5e71760 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/CurrencyField.tsx @@ -0,0 +1,33 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { CurrencyInput, type CurrencyInputProps } from '@/components/inputs' + +type CurrencyFieldProps = { + control: Control + name: FieldPathByValue +} & Omit + +export function CurrencyField({ + control, + name, + ...rest +}: CurrencyFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/DateField.tsx b/packages/ui/src/components/form/components/fields/DateField.tsx new file mode 100644 index 000000000..8484846f3 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/DateField.tsx @@ -0,0 +1,42 @@ +import dayjs from 'dayjs' +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { DateInput, type DateInputProps } from '@/components/inputs' + +type DateFieldProps = { + control: Control + name: FieldPathByValue +} & Omit + +export function DateField({ + control, + name, + ...rest +}: DateFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + const val = field.value as unknown + + const dateValue = val + ? val instanceof Date + ? val + : dayjs(val as string).toDate() + : null + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/FileField.tsx b/packages/ui/src/components/form/components/fields/FileField.tsx new file mode 100644 index 000000000..1a134f7b3 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/FileField.tsx @@ -0,0 +1,41 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { FileInput, type FilePickerSourceConfig, type FileValue } from '@/components/inputs' + +type FileFieldProps = { + control: Control + name: FieldPathByValue + icon: string + label: string + reminderText?: string + onImageRemoved?: () => void + required?: boolean + namespace?: string + disabled?: boolean + sources?: FilePickerSourceConfig + mimeTypes?: Record +} + +export function FileField({ + control, + name, + ...rest +}: FileFieldProps) { + const { field } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/IconField.tsx b/packages/ui/src/components/form/components/fields/IconField.tsx new file mode 100644 index 000000000..0572f6249 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/IconField.tsx @@ -0,0 +1,33 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { IconInput, type IconInputProps } from '@/components/inputs' + +type IconFieldProps = { + control: Control + name: FieldPathByValue +} & Omit + +export function IconField({ + control, + name, + ...rest +}: IconFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/ListboxField.tsx b/packages/ui/src/components/form/components/fields/ListboxField.tsx new file mode 100644 index 000000000..941c92545 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/ListboxField.tsx @@ -0,0 +1,199 @@ +import { Fragment } from 'react' +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { ListboxInput, ListboxOption } from '@/components/inputs' +import { Box, Flex, Icon, Text } from '@/components/primitives' + +type ListboxOptionType = { + value: TOption + text: string + icon?: string + color?: string +} + +type ListboxFieldProps = { + control: Control + name: FieldPathByValue + icon: string + label: string + multiple?: boolean + disabled?: boolean + namespace?: string + required?: boolean + options: ListboxOptionType[] + actionButtonOption?: { + text: string + onClick: () => void + icon: string + } +} + +function OptionColorAndIcon({ + color, + icon +}: { + color?: string + icon?: string +}) { + if (color && icon) { + return + } + + if (!color) { + return + } + + return ( + + ) +} + +function ListboxButtonContent({ + multiple, + value, + options +}: { + multiple?: boolean + value: unknown + options: ListboxOptionType[] +}) { + if (multiple === true && Array.isArray(value)) { + return ( + + {value.length > 0 && + value.map(function (item: TOption, i: number) { + const target = options.find(function (l) { + return l.value === item + }) + + return ( + + + + {target?.text ?? 'None'} + + {i !== value.length - 1 && ( + + )} + + ) + })} + + ) + } + + const targetOption = options.find(function (l) { + return l.value === value + }) + + if (!targetOption) { + return None + } + + return ( + + + {targetOption.text} + + ) +} + +export function ListboxField({ + control, + name, + icon, + label, + multiple = false, + disabled = false, + namespace, + required = false, + options, + actionButtonOption +}: ListboxFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + function handleListboxChange(val: unknown) { + let cleanVal = val + + if (Array.isArray(cleanVal) && cleanVal.includes(null)) { + cleanVal = cleanVal.filter(function (v) { + return v !== null + }) + } + + if (cleanVal === null) { + return + } + + field.onChange(cleanVal) + } + + function handleRenderContent() { + return ( + + ) + } + + return ( + + {options.map(function ({ text, color, icon: optIcon, value: v }) { + return ( + + ) + })} + {actionButtonOption && ( + + )} + + ) +} diff --git a/packages/ui/src/components/form/components/fields/LocationField.tsx b/packages/ui/src/components/form/components/fields/LocationField.tsx new file mode 100644 index 000000000..0060f29b0 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/LocationField.tsx @@ -0,0 +1,44 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { type Location, LocationInput } from '@/components/inputs' + +type LocationFieldProps = { + control: Control + name: FieldPathByValue + icon?: string + label: string + required?: boolean + disabled?: boolean + autoFocus?: boolean + namespace?: string +} + +export function LocationField({ + control, + name, + disabled = false, + required = false, + autoFocus = false, + ...rest +}: LocationFieldProps) { + const { field } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/NumberField.tsx b/packages/ui/src/components/form/components/fields/NumberField.tsx new file mode 100644 index 000000000..e59120844 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/NumberField.tsx @@ -0,0 +1,33 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { NumberInput, type NumberInputProps } from '@/components/inputs' + +type NumberFieldProps = { + control: Control + name: FieldPathByValue +} & Omit + +export function NumberField({ + control, + name, + ...rest +}: NumberFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/RRuleField.tsx b/packages/ui/src/components/form/components/fields/RRuleField.tsx new file mode 100644 index 000000000..54d15ccfc --- /dev/null +++ b/packages/ui/src/components/form/components/fields/RRuleField.tsx @@ -0,0 +1,33 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { RRuleInput } from '@/components/inputs' + +type RRuleFieldProps = { + control: Control + name: FieldPathByValue + hasDuration?: boolean +} + +export function RRuleField({ + control, + name, + hasDuration = false +}: RRuleFieldProps) { + const { field } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/SliderField.tsx b/packages/ui/src/components/form/components/fields/SliderField.tsx new file mode 100644 index 000000000..ea7bbf25e --- /dev/null +++ b/packages/ui/src/components/form/components/fields/SliderField.tsx @@ -0,0 +1,32 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { SliderInput, type SliderInputProps } from '@/components/inputs' + +type SliderFieldProps = { + control: Control + name: FieldPathByValue +} & Omit + +export function SliderField({ + control, + name, + ...rest +}: SliderFieldProps) { + const { field } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/TextAreaField.tsx b/packages/ui/src/components/form/components/fields/TextAreaField.tsx new file mode 100644 index 000000000..863cd4c3c --- /dev/null +++ b/packages/ui/src/components/form/components/fields/TextAreaField.tsx @@ -0,0 +1,33 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { TextAreaInput, type TextAreaInputProps } from '@/components/inputs' + +type TextAreaFieldProps = { + control: Control + name: FieldPathByValue +} & Omit + +export function TextAreaField({ + control, + name, + ...rest +}: TextAreaFieldProps) { + const { field, fieldState } = useController({ + control, + name + }) + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/TextField.tsx b/packages/ui/src/components/form/components/fields/TextField.tsx new file mode 100644 index 000000000..c0402f780 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/TextField.tsx @@ -0,0 +1,62 @@ +import { + type Control, + type FieldPathByValue, + type FieldValues, + useController +} from 'react-hook-form' + +import { QRCodeScanner, TextInput, type TextInputProps } from '@/components/inputs' +import { useModalStore } from '@/providers' + +type TextFieldProps = { + control: Control + name: FieldPathByValue + qrScanner?: boolean +} & Omit + +export function TextField({ + control, + name, + qrScanner = false, + actionButtonProps, + ...rest +}: TextFieldProps) { + const { open } = useModalStore() + const { field, fieldState } = useController({ + control, + name + }) + + function handleQRScanned(data: string) { + field.onChange(data) + } + + function openQRScanner() { + open(QRCodeScanner, { + onScanned: handleQRScanned + }) + } + + const computedActionButtonProps = actionButtonProps + ? actionButtonProps + : qrScanner + ? { + icon: 'tabler:qrcode', + onClick: openQRScanner + } + : undefined + + const textInputProps = { + ...rest, + actionButtonProps: computedActionButtonProps, + errorMsg: fieldState.error?.message, + value: field.value ?? '', + onChange: field.onChange + } as TextInputProps + + return ( + + ) +} diff --git a/packages/ui/src/components/form/components/fields/index.ts b/packages/ui/src/components/form/components/fields/index.ts new file mode 100644 index 000000000..f23a26c88 --- /dev/null +++ b/packages/ui/src/components/form/components/fields/index.ts @@ -0,0 +1,25 @@ +export * from './NumberField' + +export * from './CheckboxField' + +export * from './ColorField' + +export * from './CurrencyField' + +export * from './DateField' + +export * from './FileField' + +export * from './IconField' + +export * from './ListboxField' + +export * from './LocationField' + +export * from './RRuleField' + +export * from './SliderField' + +export * from './TextAreaField' + +export * from './TextField' diff --git a/packages/ui/src/components/form/hooks/createDefaultValues.test.ts b/packages/ui/src/components/form/hooks/createDefaultValues.test.ts new file mode 100644 index 000000000..76f60a681 --- /dev/null +++ b/packages/ui/src/components/form/hooks/createDefaultValues.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { z } from 'zod' + +import { createDefaultValues } from './createDefaultValues' + +describe('createDefaultValues', () => { + it('should resolve string schema to empty string', () => { + const schema = z.string() + expect(createDefaultValues(schema)).toBe('') + }) + + it('should resolve number schema to 0', () => { + const schema = z.number() + expect(createDefaultValues(schema)).toBe(0) + }) + + it('should resolve boolean schema to false', () => { + const schema = z.boolean() + expect(createDefaultValues(schema)).toBe(false) + }) + + it('should resolve array schema to empty array', () => { + const schema = z.array(z.string()) + expect(createDefaultValues(schema)).toEqual([]) + }) + + it('should resolve object schema to an object with default values recursively', () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + active: z.boolean(), + tags: z.array(z.string()), + nested: z.object({ + value: z.string() + }) + }) + + expect(createDefaultValues(schema)).toEqual({ + name: '', + age: 0, + active: false, + tags: [], + nested: { + value: '' + } + }) + }) + + it('should resolve optional schema to undefined', () => { + const schema = z.string().optional() + expect(createDefaultValues(schema)).toBeUndefined() + }) + + it('should resolve nullable schema to null', () => { + const schema = z.string().nullable() + expect(createDefaultValues(schema)).toBeNull() + }) + + it('should resolve default schema to the specified default value', () => { + const schema1 = z.string().default('hello') + const schema2 = z.string().default(function () { + return 'dynamic-hello' + }) + + expect(createDefaultValues(schema1)).toBe('hello') + expect(createDefaultValues(schema2)).toBe('dynamic-hello') + }) + + it('should resolve readonly schema to its inner default value', () => { + const schema = z.string().readonly() + expect(createDefaultValues(schema)).toBe('') + }) + + it('should resolve lazy schema to its inner default value', () => { + const schema = z.lazy(function () { + return z.string() + }) + expect(createDefaultValues(schema)).toBe('') + }) + + it('should resolve pipe/transform schema to its inner default value', () => { + const schema = z.string().transform(function (val) { + return val.length + }) + expect(createDefaultValues(schema)).toBe('') + }) + + it('should resolve unsupported schemas to undefined', () => { + const schema1 = z.date() + const schema2 = z.enum(['active', 'inactive']) + + expect(createDefaultValues(schema1)).toBeUndefined() + expect(createDefaultValues(schema2)).toBeUndefined() + }) +}) diff --git a/packages/ui/src/components/form/hooks/createDefaultValues.ts b/packages/ui/src/components/form/hooks/createDefaultValues.ts new file mode 100644 index 000000000..5217a45dd --- /dev/null +++ b/packages/ui/src/components/form/hooks/createDefaultValues.ts @@ -0,0 +1,70 @@ +/** + * ⚠️ WARNING + * + * Do not add business-specific defaults here. + * + * This utility only derives defaults from Zod types. + * + * Keep this utility dumb. Very dumb. Dumber than you think it should be. + */ +import { z } from 'zod' + +export function createDefaultValues( + schema: TSchema +): z.infer { + if (schema instanceof z.ZodOptional) { + return undefined as z.infer + } + + if (schema instanceof z.ZodNullable) { + return null as z.infer + } + + if (schema instanceof z.ZodDefault) { + return schema.def.defaultValue as z.infer + } + + if (schema instanceof z.ZodPipe) { + return createDefaultValues(schema.in as z.ZodTypeAny) as z.infer + } + + if (schema instanceof z.ZodReadonly || schema instanceof z.ZodLazy) { + return createDefaultValues( + schema.unwrap() as z.ZodTypeAny + ) as z.infer + } + + if (schema instanceof z.ZodString) { + return '' as z.infer + } + + if (schema instanceof z.ZodNumber) { + return 0 as z.infer + } + + if (schema instanceof z.ZodBoolean) { + return false as z.infer + } + + if (schema instanceof z.ZodArray) { + return [] as unknown as z.infer + } + + if (schema instanceof z.ZodEnum) { + return schema.options[0] as z.infer + } + + if (schema instanceof z.ZodObject) { + const shape = schema.shape + + const objDefaults: Record = {} + + for (const key in shape) { + objDefaults[key] = createDefaultValues(shape[key]) + } + + return objDefaults as z.infer + } + + return undefined as z.infer +} diff --git a/packages/ui/src/components/form/index.tsx b/packages/ui/src/components/form/index.tsx new file mode 100644 index 000000000..6e7b74483 --- /dev/null +++ b/packages/ui/src/components/form/index.tsx @@ -0,0 +1,5 @@ +export * from './components/FormModal' + +export * from './components/fields' + +export * from './hooks/createDefaultValues' diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts new file mode 100644 index 000000000..bec13dbf3 --- /dev/null +++ b/packages/ui/src/components/index.ts @@ -0,0 +1,21 @@ +export * from './primitives' + +export * from './auth' + +export * from './data-display' + +export * from './inputs' + +export * from './feedback' + +export * from './inputs' + +export * from './form' + +export * from './layout' + +export * from './navigation' + +export * from './overlays' + +export * from './utilities' diff --git a/packages/ui/src/components/inputs/ColorInput/index.tsx b/packages/ui/src/components/inputs/ColorInput/index.tsx index 8a4596b21..a335669f6 100644 --- a/packages/ui/src/components/inputs/ColorInput/index.tsx +++ b/packages/ui/src/components/inputs/ColorInput/index.tsx @@ -14,7 +14,7 @@ import type { InputVariants } from '../shared/types' import { autoFocusableRef } from '../shared/utils/autoFocusableRef' import { ColorPickerModal } from './ColorPickerModal' -interface ColorInputProps { +export interface ColorInputProps { /** The label text displayed above the color input field. Required for 'classic' style. */ label?: string /** The current color value in hex format (e.g., "#FF0000"). */ diff --git a/packages/ui/src/components/inputs/CurrencyInput/index.tsx b/packages/ui/src/components/inputs/CurrencyInput/index.tsx index 89936eb9a..08aa62bf1 100644 --- a/packages/ui/src/components/inputs/CurrencyInput/index.tsx +++ b/packages/ui/src/components/inputs/CurrencyInput/index.tsx @@ -13,7 +13,7 @@ import { useInputLabel } from '../shared/hooks/useInputLabel' import type { InputVariants } from '../shared/types' import { autoFocusableRef } from '../shared/utils/autoFocusableRef' -type CurrencyInputProps = { +export type CurrencyInputProps = { /** The currency symbol to display, or the currency code (e.g., "$", "€", "USD"). */ prefix?: string /** The label text displayed above the currency input field. Required for 'classic' style. */ diff --git a/packages/ui/src/components/inputs/DateInput/index.tsx b/packages/ui/src/components/inputs/DateInput/index.tsx index ab1a1e8a3..c7c6780bc 100644 --- a/packages/ui/src/components/inputs/DateInput/index.tsx +++ b/packages/ui/src/components/inputs/DateInput/index.tsx @@ -22,7 +22,7 @@ import { CalendarHeader } from './components/CalendarHeader' /** * Props for the DateInput component. */ -interface DateInputProps { +export interface DateInputProps { variant?: 'classic' | 'plain' /** The label text displayed above the date input field. Required for 'classic' style. */ label?: string diff --git a/packages/ui/src/components/inputs/FileInput/FileInput.stories.tsx b/packages/ui/src/components/inputs/FileInput/FileInput.stories.tsx index edcafcb3e..9788dbd17 100644 --- a/packages/ui/src/components/inputs/FileInput/FileInput.stories.tsx +++ b/packages/ui/src/components/inputs/FileInput/FileInput.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { useState } from 'react' import { FileInput } from './index' +import type { FileValue } from './index' const meta = { component: FileInput @@ -13,32 +14,24 @@ type Story = StoryObj export const Default: Story = { args: { - acceptedMimeTypes: { + mimeTypes: { application: ['pdf'], image: ['jpeg', 'png'] }, - file: null, + value: { type: 'empty' }, icon: 'tabler:file', label: 'Upload Image', namespace: 'namespace', - preview: null, - setData: () => {} + onChange: function () {} }, - render: args => { - const [image, setImage] = useState(null) - - const [preview, setPreview] = useState(null) + render: function (args) { + const [val, setVal] = useState({ type: 'empty' }) return ( { - setImage(file) - setPreview(preview) - }} + value={val} + onChange={setVal} /> ) } @@ -46,33 +39,25 @@ export const Default: Story = { export const Disabled: Story = { args: { - acceptedMimeTypes: { + mimeTypes: { application: ['pdf'], image: ['jpeg', 'png'] }, - file: null, + value: { type: 'empty' }, icon: 'tabler:file', label: 'Upload Image', namespace: 'namespace', - preview: null, - setData: () => {} + onChange: function () {} }, - render: args => { - const [image, setImage] = useState(null) - - const [preview, setPreview] = useState(null) + render: function (args) { + const [val, setVal] = useState({ type: 'empty' }) return ( { - setImage(file) - setPreview(preview) - }} + value={val} + onChange={setVal} /> ) } diff --git a/packages/ui/src/components/inputs/FileInput/FilePickerModal/index.tsx b/packages/ui/src/components/inputs/FileInput/FilePickerModal/index.tsx index b74e23e75..b70de20af 100644 --- a/packages/ui/src/components/inputs/FileInput/FilePickerModal/index.tsx +++ b/packages/ui/src/components/inputs/FileInput/FilePickerModal/index.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { usePromiseLoading } from '@lifeforge/shared' -import { Button } from '@/components/inputs' +import { Button, type FilePickerSourceConfig } from '@/components/inputs' import { Tabs } from '@/components/navigation' import { ModalHeader } from '@/components/overlays' import { Box, Flex } from '@/components/primitives' @@ -14,22 +14,12 @@ import { LocalUpload } from './components/LocalUpload' import { Pixabay } from './components/Pixabay' export function FilePickerModal({ - data: { - enablePixabay = false, - enableUrl = false, - enableAI = false, - defaultAIPrompt = '', - acceptedMimeTypes, - onSelect - }, + data: { sources, mimeTypes, onSelect }, onClose }: { data: { - enablePixabay?: boolean - enableUrl?: boolean - enableAI?: boolean - defaultAIPrompt?: string - acceptedMimeTypes?: Record + sources: FilePickerSourceConfig + mimeTypes?: Record onSelect: (file: string | File, preview: string | null) => Promise } onClose: () => void @@ -62,15 +52,11 @@ export function FilePickerModal({ title="imagePicker.title" onClose={onClose} /> - {(enablePixabay || enableUrl || enableAI) && ( + {Object.values(sources).some(e => e) && ( - name === 'local' || - (name === 'pixabay' && enablePixabay) || - (name === 'url' && enableUrl) || - (name === 'ai' && enableAI) + name => name === 'local' || sources[name] )} items={[ { @@ -112,7 +98,7 @@ export function FilePickerModal({ case 'local': return ( void + onImageRemoved?: () => void +}) { + return ( + + + { + if (value.type !== 'file') return 'tabler:file' + + const ext = ( + value.source === 'existing' + ? value.filename + : value.source === 'upload' + ? value.file.name + : value.url.split('/').pop()?.split('?')[0] || '' + ) + .split('.') + .pop() + ?.toLowerCase() + + return ( + FILE_ICONS[ext as keyof typeof FILE_ICONS] || + 'tabler:file' + ) + })()} + size="1.5rem" + /> + + {(() => { + if (value.type !== 'file') return '' + if (value.source === 'existing') return value.filename + if (value.source === 'upload') return value.file.name + + return value.url + })()} + + + + + + {reminderText || + t('fileInputSupportedFormat', { + format: acceptedMimeTypes + ? Object.entries(acceptedMimeTypes) + .flatMap(function ([type, exts]) { + return exts.map(function (ext) { + return `${type}/${ext}` + }) + }) + .join(', ') || 'N/A' + : 'N/A' + })} + + + ) +} diff --git a/packages/ui/src/components/inputs/FileInput/components/PreviewFileDisplay.tsx b/packages/ui/src/components/inputs/FileInput/components/PreviewFileDisplay.tsx new file mode 100644 index 000000000..27242bb4a --- /dev/null +++ b/packages/ui/src/components/inputs/FileInput/components/PreviewFileDisplay.tsx @@ -0,0 +1,37 @@ +import Zoom from 'react-medium-image-zoom' + +import { Button } from '@/components/inputs' +import { Box } from '@/components/primitives' + +import type { FileValue } from '..' + +export function PreviewFileDisplay({ + previewUrl, + onChange, + onImageRemoved +}: { + previewUrl: string + onChange: (value: FileValue) => void + onImageRemoved?: () => void +}) { + return ( + + + + + + + + + ) +} diff --git a/packages/ui/src/components/inputs/FileInput/index.tsx b/packages/ui/src/components/inputs/FileInput/index.tsx index de254ba3b..aa58d47fd 100644 --- a/packages/ui/src/components/inputs/FileInput/index.tsx +++ b/packages/ui/src/components/inputs/FileInput/index.tsx @@ -1,75 +1,110 @@ import { useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import Zoom from 'react-medium-image-zoom' -import { Button } from '@/components/inputs' -import { Box, Flex, Icon, Text } from '@/components/primitives' +import { Flex, Icon, Text } from '@/components/primitives' import { useModalStore } from '@/providers' import { colorWithOpacity } from '@/system' import { useInputLabel } from '../shared/hooks/useInputLabel' import { FilePickerModal } from './FilePickerModal' -import { FILE_ICONS } from './FilePickerModal/constants/file_icons' +import { CompactFileDisplay } from './components/CompactFileDisplay' +import { EmptyFileInput } from './components/EmptyFileInput' +import { PreviewFileDisplay } from './components/PreviewFileDisplay' -export type FileData = { - file: string | File | null - preview: string | null +export type FileValue = + | { + type: 'empty' + } + | { + type: 'file' + source: 'existing' + id: string + filename: string + preview?: string + } + | { + type: 'file' + source: 'upload' + file: File + preview?: string + } + | { + type: 'file' + source: 'url' + url: string + preview?: string + } + +export interface FilePickerSourceConfig { + pixabay?: boolean + url?: boolean + ai?: + | { + defaultPrompt: string + } + | false } export function FileInput({ icon, label, reminderText, - file, - preview, - setData, + value, + onChange, onImageRemoved, required, namespace, disabled, - enablePixabay = false, - enableUrl = false, - enableAI = false, - defaultAIPrompt = '', - acceptedMimeTypes + sources = { + pixabay: false, + ai: false, + url: false + }, + mimeTypes }: { icon: string label: string reminderText?: string - file: string | File | null - preview: string | null - setData: (data: { - file: string | File | null - preview: string | null - }) => void + value: FileValue + onChange: (value: FileValue) => void onImageRemoved?: () => void required?: boolean namespace?: string disabled?: boolean - enablePixabay?: boolean - enableUrl?: boolean - enableAI?: boolean - defaultAIPrompt?: string - acceptedMimeTypes?: Record + sources?: FilePickerSourceConfig + mimeTypes?: Record }) { const { open } = useModalStore() - const { t } = useTranslation('common.misc') - const inputLabel = useInputLabel({ namespace, label }) - const handleFilePickerOpen = useCallback(() => { - open(FilePickerModal, { - enablePixabay, - enableUrl, - enableAI, - defaultAIPrompt, - acceptedMimeTypes, - onSelect: async (file: string | File, preview: string | null) => { - setData({ file, preview }) - } - }) - }, [enablePixabay, enableUrl, enableAI, defaultAIPrompt, acceptedMimeTypes]) + const handleFilePickerOpen = useCallback( + function () { + open(FilePickerModal, { + sources, + mimeTypes, + onSelect: async function (file: string | File, preview: string | null) { + if (file instanceof File) { + onChange({ + type: 'file', + source: 'upload', + file, + preview: preview ?? undefined + }) + } else { + onChange({ + type: 'file', + source: 'url', + url: file, + preview: preview ?? undefined + }) + } + } + }) + }, + [sources, mimeTypes, onChange] + ) + + const previewUrl = value.type === 'file' ? value.preview : undefined return ( - {!file || file === 'removed' ? ( - <> - - - - - {reminderText || - t('fileInputSupportedFormat', { - format: acceptedMimeTypes - ? Object.entries(acceptedMimeTypes) - .flatMap(([type, exts]) => - exts.map(ext => `${type}/${ext}`) - ) - .join(', ') || 'N/A' - : 'N/A' - })} - - + {value.type === 'empty' ? ( + ) : ( <> - {preview && - (preview.startsWith('http') || - preview.startsWith('blob:') || - preview.startsWith('data:')) ? ( - - - - - - - - + {previewUrl && + (previewUrl.startsWith('http') || + previewUrl.startsWith('blob:') || + previewUrl.startsWith('data:')) ? ( + ) : ( - - - - - {file instanceof File - ? file.name - : file === 'keep' - ? (preview ?? '') - : (preview ?? file)} - - - + ), icon: 'tabler:send', - onClose: () => {}, + onClose: function () {}, title: 'Publish Post' }, - render: args => ( - - - - ) + render: function (args) { + return ( + + + + ) + } } /** @@ -177,10 +196,13 @@ export const WithActionButtonVariant: Story = { */ export const KitchenSink: Story = { args: { - actionButtonProps: { - icon: 'tabler:settings', - onClick: () => {} - }, + headerActions: ( + - ) - } -} - -/** - * Story demonstrating a FormModal with a Listbox input that includes an action button option. - * This action button allows users to perform a custom action, such as opening another modal for adding new options. - */ -export const ListboxInputWithActionButtonOption: Story = { - args: { - ui: { - icon: 'tabler:forms', - loading: false, - namespace: '', - onClose: () => {}, - submitButton: 'create', - title: 'Form Modal with Listbox Input Action Button Option' - } - } as never, - render: () => { - const { open } = useModalStore() - - const FormWithListboxInputActionButtonOption = ({ - onClose - }: { - onClose: () => void - }) => { - const { formProps } = defineForm<{ choice: string | null }>({ - icon: 'tabler:forms', - namespace: '', - onClose, - submitButton: 'create', - title: 'Form Modal' - }) - .typesMap({ - choice: 'listbox' - }) - .setupFields({ - choice: { - actionButtonOption: { - icon: 'tabler:plus', - onClick: () => { - alert('Add Option clicked!') - }, - text: 'Add Option' - }, - icon: 'tabler:list', - label: 'Choose an option', - multiple: false, - options: [ - { icon: 'tabler:number-1', text: 'Option 1', value: 'option1' }, - { icon: 'tabler:number-2', text: 'Option 2', value: 'option2' }, - { icon: 'tabler:number-3', text: 'Option 3', value: 'option3' } - ], - required: true - } - }) - .onSubmit(async formData => { - alert(`Form submitted with data: ${JSON.stringify(formData)}`) - }) - .build() - - return - } - - return ( - - ) - } -} - -export const DisabledFieldsWithTooltips: Story = { - args: { - ui: { - icon: 'tabler:forms', - loading: false, - namespace: '', - onClose: () => {}, - submitButton: 'create', - title: 'Form Modal with Disabled Fields and Tooltips' - } - } as never, - render: () => { - const { open } = useModalStore() - - const FormWithDisabledFieldsAndTooltips = ({ - onClose - }: { - onClose: () => void - }) => { - const { formProps } = defineForm<{ username: string; email: string }>({ - icon: 'tabler:forms', - namespace: '', - onClose, - submitButton: 'create', - title: 'Form Modal' - }) - .typesMap({ - email: 'text', - username: 'text' - }) - .setupFields({ - email: { - disabled: true, - disabledReason: - 'Email changes are restricted for security reasons.', - icon: 'tabler:mail', - label: 'Email', - placeholder: 'john_doe@example.com' - }, - username: { - disabled: true, - disabledReason: 'Your username cannot be changed once set.', - icon: 'tabler:user', - label: 'Username', - placeholder: 'john_doe' - } - }) - .initialData({ - email: 'john_doe@example.com', - username: 'john_doe' - }) - .onSubmit(async formData => { - alert(`Form submitted with data: ${JSON.stringify(formData)}`) - }) - .build() - - return - } - - return ( - - ) - } -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCheckboxInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCheckboxInput.tsx deleted file mode 100644 index b18148e92..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCheckboxInput.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import _ from 'lodash' -import { useTranslation } from 'react-i18next' - -import { Switch } from '@/components/inputs' -import { Flex, Icon, Text } from '@/components/primitives' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type CheckboxFieldProps = BaseFieldProps & { - type: 'checkbox' - icon: string -} - -export function FormCheckboxInput({ - field, - value, - namespace, - handleChange -}: FormInputProps) { - const { t } = useTranslation(namespace) - - return ( - - - - - - {t([ - ['inputs', _.camelCase(field.label), 'label'] - .filter(e => e) - .join('.'), - ['inputs', _.camelCase(field.label)].filter(e => e).join('.') - ])} - - - { - handleChange(!value) - }} - /> - - {field.errorMsg && ( - - {field.errorMsg} - - )} - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormColorInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormColorInput.tsx deleted file mode 100644 index 084e6dfee..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormColorInput.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ColorInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type ColorFieldProps = BaseFieldProps & { - type: 'color' -} - -export function FormColorInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCurrencyInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCurrencyInput.tsx deleted file mode 100644 index e9942932d..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormCurrencyInput.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { CurrencyInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type CurrencyFieldProps = BaseFieldProps & { - type: 'currency' - icon: string -} - -export function FormCurrencyInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormDateInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormDateInput.tsx deleted file mode 100644 index 52eca5e93..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormDateInput.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { DateInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type DateFieldProps = BaseFieldProps & { - type: 'datetime' - icon: string - hasTime?: boolean -} - -export function FormDateInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormFileInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormFileInput.tsx deleted file mode 100644 index 2d69e5939..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormFileInput.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { type FileData, FileInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type FileFieldProps = BaseFieldProps< - FileData, - string | File -> & { - type: 'file' - icon: string - optional: TOptional - onFileRemoved?: () => void - enablePixabay?: boolean - enableUrl?: boolean - enableAIImageGeneration?: boolean - defaultImageGenerationPrompt?: string - acceptedMimeTypes?: Record -} - -export function FormFileInput({ - field, - value, - namespace, - handleChange -}: FormInputProps) { - return ( - { - handleChange({ - file: 'removed', - preview: null - }) - }} - /> - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormIconInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormIconInput.tsx deleted file mode 100644 index dfb3c1d71..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormIconInput.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { IconInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type IconFieldProps = BaseFieldProps & { - type: 'icon' -} - -export function FormIconInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormListboxInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormListboxInput.tsx deleted file mode 100644 index 10f9d96e8..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormListboxInput.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { Fragment } from 'react/jsx-runtime' - -import { ListboxInput, ListboxOption } from '@/components/inputs' -import { Box, Flex, Icon, Text } from '@/components/primitives' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type ListboxFieldProps< - TOption = any, - TMultiple extends boolean = boolean, - TFormState = any -> = BaseFieldProps< - TMultiple extends true ? TOption[] : TOption | null, - TMultiple extends true ? TOption[] : TOption | undefined -> & { - type: 'listbox' - icon: string - multiple: TMultiple - options: - | Array<{ - value: TOption - text: string - icon?: string - color?: string - }> - | ((formState: TFormState) => Array<{ - value: TOption - text: string - icon?: string - color?: string - }>) - actionButtonOption?: { - text: string - onClick: () => void - icon: string - } -} - -function OptionColorAndIcon({ - color, - icon -}: { - color?: string - icon?: string -}) { - if (color && icon) { - return - } - - if (!color) { - return - } - - return ( - - ) -} - -function ListboxButtonContent({ - field, - value, - options -}: { - field: ListboxFieldProps - value: any - options: { - value: string - text: string - icon?: string - color?: string - }[] -}) { - if (field.multiple === true && Array.isArray(value)) { - return ( - - {value.length > 0 && - value.map((item: string, i: number) => ( - - - l.value === item)?.icon ?? ''} - style={{ - color: options.find(l => l.value === item)?.color - }} - /> - - {options.find(l => l.value === item)?.text ?? 'None'} - - - {i !== value.length - 1 && ( - - )} - - ))} - - ) - } - - const targetOption = options.find(l => l.value === value) - - if (!targetOption) { - return None - } - - return ( - - - - {options.find(l => l.value === value)?.text ?? 'None'} - - - ) -} - -export function FormListboxInput({ - field, - value, - namespace, - handleChange, - options -}: FormInputProps) { - if (!options) { - return null - } - - return ( - ( - - )} - disabled={field.disabled} - errorMsg={field.errorMsg} - hasActionButton={!!field.actionButtonOption} - icon={field.icon} - label={field.label} - multiple={!!field.multiple} - namespace={namespace} - required={field.required} - value={value} - onChange={val => { - if (Array.isArray(val) && val.includes(null)) { - val = val.filter(v => v !== null) - } - - if (val === null) { - return - } - - handleChange(val) - }} - > - {options.map(({ text, color, icon, value: v }) => ( - - ))} - {field.actionButtonOption && ( - - )} - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormLocationInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormLocationInput.tsx deleted file mode 100644 index 15a2b08c0..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormLocationInput.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { type Location, LocationInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type LocationFieldProps = BaseFieldProps< - Location | null, - Location, - true -> & { - type: 'location' -} - -export function FormLocationInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - return ( - handleChange(value)} - /> - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormNumberInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormNumberInput.tsx deleted file mode 100644 index 031d55b7f..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormNumberInput.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { NumberInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type NumberFieldProps = BaseFieldProps & { - type: 'number' - icon: string -} - -export function FormNumberInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormRRuleInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormRRuleInput.tsx deleted file mode 100644 index a1710a297..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormRRuleInput.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { RRuleInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type RRuleFieldProps = BaseFieldProps & { - type: 'rrule' - hasDuration?: boolean -} - -export function FormRRuleInput({ - field, - value, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormSliderInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormSliderInput.tsx deleted file mode 100644 index a5127a8b2..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormSliderInput.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { SliderInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type SliderFieldProps = BaseFieldProps & { - type: 'slider' - icon?: string - min?: number - max?: number - step?: number -} - -export function FormSliderInput({ - field, - value, - namespace, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextAreaInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextAreaInput.tsx deleted file mode 100644 index 0c50aa91a..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextAreaInput.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { TextAreaInput } from '@/components/inputs' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type TextAreaFieldProps = BaseFieldProps & { - type: 'textarea' - icon: string - placeholder: string -} - -export function FormTextAreaInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - return ( - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextInput.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextInput.tsx deleted file mode 100644 index e4e40dad6..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/components/FormTextInput.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { QRCodeScanner, TextInput } from '@/components/inputs' -import { useModalStore } from '@/providers' - -import type { - BaseFieldProps, - FormInputProps -} from '../../../typescript/form.types' - -export type TextFieldProps = BaseFieldProps & { - type: 'text' - icon: string - isPassword?: boolean - placeholder: string - qrScanner?: boolean - actionButtonProps?: React.ComponentProps< - typeof TextInput - >['actionButtonProps'] -} - -export function FormTextInput({ - field, - value, - autoFocus, - namespace, - handleChange -}: FormInputProps) { - const { open } = useModalStore() - - const openQRScanner = () => { - open(QRCodeScanner, { - onScanned: data => { - handleChange(data) - } - }) - } - - return ( - <> - - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/index.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/index.tsx deleted file mode 100644 index b10140f66..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/FormInputs/index.tsx +++ /dev/null @@ -1,202 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { memo, useMemo } from 'react' - -import { Flex } from '@/components/primitives' -import { Tooltip } from '@/components/utilities' - -import { - type FormFieldPropsUnion, - type FormInputProps, - type FormState -} from '../../typescript/form.types' -import { FormCheckboxInput } from './components/FormCheckboxInput' -import { FormColorInput } from './components/FormColorInput' -import { FormCurrencyInput } from './components/FormCurrencyInput' -import { FormDateInput } from './components/FormDateInput' -import { FormFileInput } from './components/FormFileInput' -import { FormIconInput } from './components/FormIconInput' -import { FormListboxInput } from './components/FormListboxInput' -import { FormLocationInput } from './components/FormLocationInput' -import { FormNumberInput } from './components/FormNumberInput' -import { FormRRuleInput } from './components/FormRRuleInput' -import { FormSliderInput } from './components/FormSliderInput' -import { FormTextAreaInput } from './components/FormTextAreaInput' -import { FormTextInput } from './components/FormTextInput' - -// Map of form field types to their corresponding components -const COMPONENT_MAP: Record< - FormFieldPropsUnion['type'], - React.FC> -> = { - text: FormTextInput, - number: FormNumberInput, - currency: FormCurrencyInput, - textarea: FormTextAreaInput, - datetime: FormDateInput, - listbox: FormListboxInput, - color: FormColorInput, - icon: FormIconInput, - location: FormLocationInput, - checkbox: FormCheckboxInput, - file: FormFileInput, - rrule: FormRRuleInput, - slider: FormSliderInput -} - -// Memoized individual form field component to prevent unnecessary rerenders -export const MemoizedFormField = memo( - ({ - id, - field, - value, - autoFocus, - namespace, - errorMsg, - options, - onFieldChange - }: { - id: string - field: FormFieldPropsUnion - value: any - autoFocus?: boolean - namespace?: string - errorMsg?: string - options?: { - value: string - text: string - icon?: string - color?: string - }[] - onFieldChange: (value: any) => void - }) => { - const fieldType = field.type as FormFieldPropsUnion['type'] - - const FormComponent = COMPONENT_MAP[fieldType] || (() => <>) - - return ( - - - {field.disabled && field.disabledReason && ( - - {field.disabledReason} - - )} - - ) - }, - (prevProps, nextProps) => { - return ( - prevProps.id === nextProps.id && - prevProps.value === nextProps.value && - prevProps.errorMsg === nextProps.errorMsg && - prevProps.onFieldChange === nextProps.onFieldChange && - JSON.stringify(prevProps.options) === JSON.stringify(nextProps.options) - ) - } -) - -MemoizedFormField.displayName = 'MemoizedFormField' - -export function FormInputs({ - fields, - autoFocusableFieldId, - conditionalFields, - data, - setData, - errorMsgs, - removeErrorMsg, - namespace -}: { - fields: Record - autoFocusableFieldId?: string - conditionalFields?: { - [K in keyof T]?: (data: T) => boolean - } - data: T - setData: React.Dispatch> - errorMsgs: Record - removeErrorMsg: (fieldId: string) => void - namespace?: string -}) { - const changeHandlers = useMemo(() => { - const handlers: Record void> = {} - - Object.keys(fields).forEach(id => { - handlers[id] = (value: any) => { - removeErrorMsg(id) - setData(prev => ({ ...prev, [id]: value })) - } - }) - - return handlers - }, [fields]) - - return ( - - {Object.entries(fields as Record).map( - ([id, field]) => { - // Render corresponding form field component based on field type - const value = data[id] - - const errorMsg = errorMsgs[id] - - const conditionalCall = conditionalFields?.[id]?.(data) - - // Determine whether there is a conditional hidden state, - // if not, the field should be visible - const conditionalHidden = - typeof conditionalCall === 'boolean' ? !conditionalCall : false - - // If the field is explicitly hidden, conditionalHidden should be overridden - const hidden = field.hidden ? true : conditionalHidden - - let options: - | { - value: string - text: string - icon?: string - color?: string - }[] - | undefined = undefined - - if (field.type === 'listbox') { - if (typeof field.options === 'function') { - options = field.options(data) - } else { - options = field.options - } - } - - if (hidden) { - return null - } - - return ( - {})} - /> - ) - } - )} - - ) -} diff --git a/packages/ui/src/components/overlays/modals/features/FormModal/components/SubmitButton.tsx b/packages/ui/src/components/overlays/modals/features/FormModal/components/SubmitButton.tsx deleted file mode 100644 index 23f7b3aa0..000000000 --- a/packages/ui/src/components/overlays/modals/features/FormModal/components/SubmitButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import _ from 'lodash' - -import { Button } from '@/components/inputs' - -export function SubmitButton({ - submitButton, - submitLoading, - onSubmitButtonClick -}: { - submitButton: 'create' | 'update' | React.ComponentProps - submitLoading: boolean - onSubmitButtonClick: () => Promise -}) { - if (typeof submitButton === 'string') { - return ( - - ) - } - - if (submitButton) { - return ( -