mirror of
https://github.com/Lifeforge-app/lifeforge.git
synced 2026-06-28 14:55:45 +00:00
feat(ui): form system overhaul
This commit is contained in:
1
apps/lifeforge--achievements
Submodule
1
apps/lifeforge--achievements
Submodule
Submodule apps/lifeforge--achievements added at 8e8aaaf918
1
apps/lifeforge--wallet
Submodule
1
apps/lifeforge--wallet
Submodule
Submodule apps/lifeforge--wallet added at a4116d2fc6
8
bun.lock
8
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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof FormModal>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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 (
|
||||
<FormModal
|
||||
form={form}
|
||||
submissionConfig={{
|
||||
handler: async function (values) {
|
||||
alert(JSON.stringify(values))
|
||||
},
|
||||
template: 'update'
|
||||
}}
|
||||
uiConfig={{
|
||||
icon: 'tabler:forms',
|
||||
onClose: onClose,
|
||||
title: 'Form Modal'
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
required
|
||||
control={form.control}
|
||||
icon="tabler:user"
|
||||
label="Name"
|
||||
name="name"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
<NumberField
|
||||
required
|
||||
control={form.control}
|
||||
icon="tabler:number-123"
|
||||
label="Age"
|
||||
name="age"
|
||||
/>
|
||||
<ColorField control={form.control} label="Color" name="color" />
|
||||
<ListboxField
|
||||
required
|
||||
control={form.control}
|
||||
icon="tabler:icons"
|
||||
label="Icon"
|
||||
name="icon"
|
||||
options={iconOptions}
|
||||
/>
|
||||
<TextField
|
||||
isPassword
|
||||
required
|
||||
actionButtonProps={{
|
||||
icon: 'tabler:dice',
|
||||
onClick: () => {
|
||||
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="••••••••"
|
||||
/>
|
||||
<TextField
|
||||
isPassword
|
||||
required
|
||||
control={form.control}
|
||||
icon="tabler:lock"
|
||||
label="Confirm Password"
|
||||
name="confirmPassword"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</FormModal>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button icon="tabler:plus" onClick={handleButtonClick}>
|
||||
Open Form Modal
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}
|
||||
111
packages/ui/src/components/form/components/FormModal/index.tsx
Normal file
111
packages/ui/src/components/form/components/FormModal/index.tsx
Normal file
@@ -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<T extends FieldValues> =
|
||||
| {
|
||||
template: keyof typeof SUBMISSION_CONFIG_TEMPLATE
|
||||
disabled?: boolean
|
||||
handler: SubmitHandler<T>
|
||||
}
|
||||
| {
|
||||
label: string
|
||||
icon: string
|
||||
disabled?: boolean
|
||||
handler: SubmitHandler<T>
|
||||
}
|
||||
|
||||
export function FormModal<T extends FieldValues>({
|
||||
form,
|
||||
uiConfig: { title, icon, namespace, loading = false, onClose, headerActions },
|
||||
submissionConfig,
|
||||
children
|
||||
}: {
|
||||
form: UseFormReturn<T>
|
||||
uiConfig: {
|
||||
title: string | React.ReactNode
|
||||
icon: string
|
||||
onClose: () => void
|
||||
namespace?: string
|
||||
loading?: boolean
|
||||
headerActions?: React.ReactNode
|
||||
}
|
||||
submissionConfig: SubmissionConfig<T>
|
||||
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 (
|
||||
<Stack gap="sm" minWidth="50vw">
|
||||
<ModalHeader
|
||||
headerActions={headerActions}
|
||||
icon={icon}
|
||||
namespace={namespace ? namespace : undefined}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!loading ? (
|
||||
<>
|
||||
{children}
|
||||
<Button
|
||||
icon={finalSubmissionConfig.icon}
|
||||
loading={submitButtonLoading}
|
||||
mt="lg"
|
||||
width="100%"
|
||||
onClick={onSubmitButtonClick}
|
||||
>
|
||||
{finalSubmissionConfig.label}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Flex
|
||||
align="center"
|
||||
direction="column"
|
||||
flex="1"
|
||||
justify="center"
|
||||
minHeight="24em"
|
||||
>
|
||||
<LoadingScreen />
|
||||
</Flex>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, boolean>
|
||||
label: string
|
||||
icon: string
|
||||
disabled?: boolean
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
export function CheckboxField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
icon,
|
||||
disabled = false,
|
||||
namespace
|
||||
}: CheckboxFieldProps<TFieldValues>) {
|
||||
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 (
|
||||
<Flex direction="column" gap="sm" width="100%">
|
||||
<Flex align="center" justify="between" py="sm">
|
||||
<Flex align="center" gap="sm">
|
||||
<Icon icon={icon} size="1.5em" />
|
||||
<Text size="lg">
|
||||
{labelText}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
value={!!field.value}
|
||||
onChange={handleSwitchChange}
|
||||
/>
|
||||
</Flex>
|
||||
{fieldState.error?.message && (
|
||||
<Text color="dangerous" size="sm">
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, string>
|
||||
} & Omit<ColorInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function ColorField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: ColorFieldProps<TFieldValues>) {
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<ColorInput
|
||||
errorMsg={fieldState.error?.message}
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, number>
|
||||
} & Omit<CurrencyInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function CurrencyField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: CurrencyFieldProps<TFieldValues>) {
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<CurrencyInput
|
||||
errorMsg={fieldState.error?.message}
|
||||
value={field.value ?? 0}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, Date | null | string>
|
||||
} & Omit<DateInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function DateField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: DateFieldProps<TFieldValues>) {
|
||||
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 (
|
||||
<DateInput
|
||||
errorMsg={fieldState.error?.message}
|
||||
value={dateValue}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, FileValue>
|
||||
icon: string
|
||||
label: string
|
||||
reminderText?: string
|
||||
onImageRemoved?: () => void
|
||||
required?: boolean
|
||||
namespace?: string
|
||||
disabled?: boolean
|
||||
sources?: FilePickerSourceConfig
|
||||
mimeTypes?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export function FileField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: FileFieldProps<TFieldValues>) {
|
||||
const { field } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<FileInput
|
||||
value={field.value ?? { type: 'empty' }}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, string>
|
||||
} & Omit<IconInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function IconField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: IconFieldProps<TFieldValues>) {
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<IconInput
|
||||
errorMsg={fieldState.error?.message}
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TOption> = {
|
||||
value: TOption
|
||||
text: string
|
||||
icon?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
type ListboxFieldProps<TFieldValues extends FieldValues, TOption> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, TOption | TOption[] | null | undefined>
|
||||
icon: string
|
||||
label: string
|
||||
multiple?: boolean
|
||||
disabled?: boolean
|
||||
namespace?: string
|
||||
required?: boolean
|
||||
options: ListboxOptionType<TOption>[]
|
||||
actionButtonOption?: {
|
||||
text: string
|
||||
onClick: () => void
|
||||
icon: string
|
||||
}
|
||||
}
|
||||
|
||||
function OptionColorAndIcon({
|
||||
color,
|
||||
icon
|
||||
}: {
|
||||
color?: string
|
||||
icon?: string
|
||||
}) {
|
||||
if (color && icon) {
|
||||
return <Icon icon={icon} style={{ color }} />
|
||||
}
|
||||
|
||||
if (!color) {
|
||||
return <Icon icon={icon ?? ''} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="inline-block"
|
||||
flexShrink="0"
|
||||
height="0.75em"
|
||||
r="full"
|
||||
style={{
|
||||
backgroundColor: color
|
||||
}}
|
||||
width="0.75em"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ListboxButtonContent<TOption>({
|
||||
multiple,
|
||||
value,
|
||||
options
|
||||
}: {
|
||||
multiple?: boolean
|
||||
value: unknown
|
||||
options: ListboxOptionType<TOption>[]
|
||||
}) {
|
||||
if (multiple === true && Array.isArray(value)) {
|
||||
return (
|
||||
<Flex align="center" gap="md" wrap="wrap">
|
||||
{value.length > 0 &&
|
||||
value.map(function (item: TOption, i: number) {
|
||||
const target = options.find(function (l) {
|
||||
return l.value === item
|
||||
})
|
||||
|
||||
return (
|
||||
<Fragment key={String(item)}>
|
||||
<Flex align="center" gap="xs">
|
||||
<Icon
|
||||
icon={target?.icon ?? ''}
|
||||
style={{
|
||||
color: target?.color
|
||||
}}
|
||||
/>
|
||||
<Text truncate>{target?.text ?? 'None'}</Text>
|
||||
</Flex>
|
||||
{i !== value.length - 1 && (
|
||||
<Icon icon="tabler:circle-filled" size="0.25em" />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const targetOption = options.find(function (l) {
|
||||
return l.value === value
|
||||
})
|
||||
|
||||
if (!targetOption) {
|
||||
return <Text>None</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align="center" gap="sm">
|
||||
<OptionColorAndIcon color={targetOption.color} icon={targetOption.icon} />
|
||||
<Text truncate>{targetOption.text}</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListboxField<TFieldValues extends FieldValues, TOption>({
|
||||
control,
|
||||
name,
|
||||
icon,
|
||||
label,
|
||||
multiple = false,
|
||||
disabled = false,
|
||||
namespace,
|
||||
required = false,
|
||||
options,
|
||||
actionButtonOption
|
||||
}: ListboxFieldProps<TFieldValues, TOption>) {
|
||||
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 (
|
||||
<ListboxButtonContent
|
||||
multiple={multiple}
|
||||
options={options}
|
||||
value={field.value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ListboxInput
|
||||
disabled={disabled}
|
||||
errorMsg={fieldState.error?.message}
|
||||
icon={icon}
|
||||
label={label}
|
||||
multiple={multiple}
|
||||
namespace={namespace}
|
||||
renderContent={handleRenderContent}
|
||||
required={required}
|
||||
value={field.value}
|
||||
onChange={handleListboxChange}
|
||||
>
|
||||
{options.map(function ({ text, color, icon: optIcon, value: v }) {
|
||||
return (
|
||||
<ListboxOption
|
||||
key={String(v)}
|
||||
color={color}
|
||||
icon={optIcon}
|
||||
label={text}
|
||||
selected={JSON.stringify(v) === JSON.stringify(field.value)}
|
||||
value={v}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{actionButtonOption && (
|
||||
<ListboxOption
|
||||
icon={actionButtonOption.icon}
|
||||
label={actionButtonOption.text}
|
||||
selected={false}
|
||||
value={null as unknown as TOption}
|
||||
onClick={actionButtonOption.onClick}
|
||||
/>
|
||||
)}
|
||||
</ListboxInput>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, Location | null>
|
||||
icon?: string
|
||||
label: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
autoFocus?: boolean
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
export function LocationField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
disabled = false,
|
||||
required = false,
|
||||
autoFocus = false,
|
||||
...rest
|
||||
}: LocationFieldProps<TFieldValues>) {
|
||||
const { field } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<LocationInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
value={field.value ?? null}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, number>
|
||||
} & Omit<NumberInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function NumberField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: NumberFieldProps<TFieldValues>) {
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
errorMsg={fieldState.error?.message}
|
||||
value={field.value ?? 0}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
type Control,
|
||||
type FieldPathByValue,
|
||||
type FieldValues,
|
||||
useController
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { RRuleInput } from '@/components/inputs'
|
||||
|
||||
type RRuleFieldProps<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, string>
|
||||
hasDuration?: boolean
|
||||
}
|
||||
|
||||
export function RRuleField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
hasDuration = false
|
||||
}: RRuleFieldProps<TFieldValues>) {
|
||||
const { field } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<RRuleInput
|
||||
hasDuration={hasDuration}
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, number>
|
||||
} & Omit<SliderInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function SliderField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: SliderFieldProps<TFieldValues>) {
|
||||
const { field } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<SliderInput
|
||||
value={field.value ?? 0}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, string>
|
||||
} & Omit<TextAreaInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function TextAreaField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
...rest
|
||||
}: TextAreaFieldProps<TFieldValues>) {
|
||||
const { field, fieldState } = useController({
|
||||
control,
|
||||
name
|
||||
})
|
||||
|
||||
return (
|
||||
<TextAreaInput
|
||||
errorMsg={fieldState.error?.message}
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<TFieldValues extends FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
name: FieldPathByValue<TFieldValues, string>
|
||||
qrScanner?: boolean
|
||||
} & Omit<TextInputProps, 'value' | 'onChange'>
|
||||
|
||||
export function TextField<TFieldValues extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
qrScanner = false,
|
||||
actionButtonProps,
|
||||
...rest
|
||||
}: TextFieldProps<TFieldValues>) {
|
||||
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 (
|
||||
<TextInput
|
||||
{...textInputProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
25
packages/ui/src/components/form/components/fields/index.ts
Normal file
25
packages/ui/src/components/form/components/fields/index.ts
Normal file
@@ -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'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
70
packages/ui/src/components/form/hooks/createDefaultValues.ts
Normal file
70
packages/ui/src/components/form/hooks/createDefaultValues.ts
Normal file
@@ -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<TSchema extends z.ZodTypeAny>(
|
||||
schema: TSchema
|
||||
): z.infer<TSchema> {
|
||||
if (schema instanceof z.ZodOptional) {
|
||||
return undefined as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodNullable) {
|
||||
return null as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodDefault) {
|
||||
return schema.def.defaultValue as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodPipe) {
|
||||
return createDefaultValues(schema.in as z.ZodTypeAny) as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodReadonly || schema instanceof z.ZodLazy) {
|
||||
return createDefaultValues(
|
||||
schema.unwrap() as z.ZodTypeAny
|
||||
) as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodString) {
|
||||
return '' as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodNumber) {
|
||||
return 0 as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodBoolean) {
|
||||
return false as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodArray) {
|
||||
return [] as unknown as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodEnum) {
|
||||
return schema.options[0] as z.infer<TSchema>
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape = schema.shape
|
||||
|
||||
const objDefaults: Record<string, unknown> = {}
|
||||
|
||||
for (const key in shape) {
|
||||
objDefaults[key] = createDefaultValues(shape[key])
|
||||
}
|
||||
|
||||
return objDefaults as z.infer<TSchema>
|
||||
}
|
||||
|
||||
return undefined as z.infer<TSchema>
|
||||
}
|
||||
5
packages/ui/src/components/form/index.tsx
Normal file
5
packages/ui/src/components/form/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './components/FormModal'
|
||||
|
||||
export * from './components/fields'
|
||||
|
||||
export * from './hooks/createDefaultValues'
|
||||
21
packages/ui/src/components/index.ts
Normal file
21
packages/ui/src/components/index.ts
Normal file
@@ -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'
|
||||
@@ -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"). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof meta>
|
||||
|
||||
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<string | File | null>(null)
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
render: function (args) {
|
||||
const [val, setVal] = useState<FileValue>({ type: 'empty' })
|
||||
|
||||
return (
|
||||
<FileInput
|
||||
{...args}
|
||||
enablePixabay
|
||||
file={image}
|
||||
preview={preview}
|
||||
setData={({ file, preview }) => {
|
||||
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<string | File | null>(null)
|
||||
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
render: function (args) {
|
||||
const [val, setVal] = useState<FileValue>({ type: 'empty' })
|
||||
|
||||
return (
|
||||
<FileInput
|
||||
{...args}
|
||||
disabled
|
||||
enablePixabay
|
||||
file={image}
|
||||
preview={preview}
|
||||
setData={({ file, preview }) => {
|
||||
setImage(file)
|
||||
setPreview(preview)
|
||||
}}
|
||||
value={val}
|
||||
onChange={setVal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<string, string[]>
|
||||
sources: FilePickerSourceConfig
|
||||
mimeTypes?: Record<string, string[]>
|
||||
onSelect: (file: string | File, preview: string | null) => Promise<void>
|
||||
}
|
||||
onClose: () => void
|
||||
@@ -62,15 +52,11 @@ export function FilePickerModal({
|
||||
title="imagePicker.title"
|
||||
onClose={onClose}
|
||||
/>
|
||||
{(enablePixabay || enableUrl || enableAI) && (
|
||||
{Object.values(sources).some(e => e) && (
|
||||
<Tabs
|
||||
currentTab={mode}
|
||||
enabled={(['local', 'url', 'pixabay', 'ai'] as const).filter(
|
||||
name =>
|
||||
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 (
|
||||
<LocalUpload
|
||||
acceptedMimeTypes={acceptedMimeTypes}
|
||||
acceptedMimeTypes={mimeTypes}
|
||||
file={file}
|
||||
preview={preview}
|
||||
setFile={setFile}
|
||||
@@ -138,7 +124,7 @@ export function FilePickerModal({
|
||||
case 'ai':
|
||||
return (
|
||||
<AIImageGenerator
|
||||
defaultPrompt={defaultAIPrompt || ''}
|
||||
defaultPrompt={sources.ai ? sources.ai.defaultPrompt : ''}
|
||||
file={file}
|
||||
setFile={setFile}
|
||||
setPreview={setPreview}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Button } from '@/components/inputs'
|
||||
import { Flex, Icon, Text } from '@/components/primitives'
|
||||
|
||||
import type { FileValue } from '..'
|
||||
import { FILE_ICONS } from '../FilePickerModal/constants/file_icons'
|
||||
|
||||
export function CompactFileDisplay({
|
||||
value,
|
||||
onChange,
|
||||
onImageRemoved
|
||||
}: {
|
||||
value: FileValue
|
||||
onChange: (value: FileValue) => void
|
||||
onImageRemoved?: () => void
|
||||
}) {
|
||||
return (
|
||||
<Flex align="center" gap="xl" justify="between" mt="md">
|
||||
<Flex align="center" gap="sm" minWidth="0">
|
||||
<Icon
|
||||
color="muted"
|
||||
icon={(() => {
|
||||
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"
|
||||
/>
|
||||
<Text truncate as="p">
|
||||
{(() => {
|
||||
if (value.type !== 'file') return ''
|
||||
if (value.source === 'existing') return value.filename
|
||||
if (value.source === 'upload') return value.file.name
|
||||
|
||||
return value.url
|
||||
})()}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Button
|
||||
icon="tabler:x"
|
||||
p="sm"
|
||||
variant="plain"
|
||||
onClick={function () {
|
||||
onChange({ type: 'empty' })
|
||||
onImageRemoved?.()
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/inputs'
|
||||
import { Box, Text } from '@/components/primitives'
|
||||
|
||||
export function EmptyFileInput({
|
||||
acceptedMimeTypes,
|
||||
reminderText,
|
||||
onSelect
|
||||
}: {
|
||||
acceptedMimeTypes?: Record<string, string[]>
|
||||
reminderText?: string
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('common.misc')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box asChild my="md">
|
||||
<Button
|
||||
icon="tabler:file-plus"
|
||||
style={{ width: '100%' }}
|
||||
variant="secondary"
|
||||
onClick={onSelect}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</Box>
|
||||
<Text align="center" as="p" color="muted" size="sm">
|
||||
{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'
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Box mt="lg">
|
||||
<Zoom zoomMargin={100}>
|
||||
<Box asChild maxHeight="24rem" r="md">
|
||||
<img alt="" src={previewUrl} />
|
||||
</Box>
|
||||
</Zoom>
|
||||
<Button
|
||||
dangerous
|
||||
icon="tabler:x"
|
||||
style={{ marginTop: '1.5rem', width: '100%' }}
|
||||
onClick={function () {
|
||||
onChange({ type: 'empty' })
|
||||
onImageRemoved?.()
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string[]>
|
||||
sources?: FilePickerSourceConfig
|
||||
mimeTypes?: Record<string, string[]>
|
||||
}) {
|
||||
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 (
|
||||
<Flex
|
||||
@@ -101,87 +136,29 @@ export function FileInput({
|
||||
</Text>
|
||||
</Flex>
|
||||
</Text>
|
||||
{!file || file === 'removed' ? (
|
||||
<>
|
||||
<Box asChild my="md">
|
||||
<Button
|
||||
icon="tabler:file-plus"
|
||||
style={{ width: '100%' }}
|
||||
variant="secondary"
|
||||
onClick={handleFilePickerOpen}
|
||||
>
|
||||
Select
|
||||
</Button>
|
||||
</Box>
|
||||
<Text align="center" as="p" color="muted" size="sm">
|
||||
{reminderText ||
|
||||
t('fileInputSupportedFormat', {
|
||||
format: acceptedMimeTypes
|
||||
? Object.entries(acceptedMimeTypes)
|
||||
.flatMap(([type, exts]) =>
|
||||
exts.map(ext => `${type}/${ext}`)
|
||||
)
|
||||
.join(', ') || 'N/A'
|
||||
: 'N/A'
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
{value.type === 'empty' ? (
|
||||
<EmptyFileInput
|
||||
acceptedMimeTypes={mimeTypes}
|
||||
reminderText={reminderText}
|
||||
onSelect={handleFilePickerOpen}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{preview &&
|
||||
(preview.startsWith('http') ||
|
||||
preview.startsWith('blob:') ||
|
||||
preview.startsWith('data:')) ? (
|
||||
<Box mt="lg">
|
||||
<Zoom zoomMargin={100}>
|
||||
<Box asChild maxHeight="24rem" r="md">
|
||||
<img alt="" src={preview} />
|
||||
</Box>
|
||||
</Zoom>
|
||||
<Button
|
||||
dangerous
|
||||
icon="tabler:x"
|
||||
style={{ marginTop: '1.5rem', width: '100%' }}
|
||||
onClick={() => {
|
||||
setData({ file: null, preview: null })
|
||||
onImageRemoved?.()
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Box>
|
||||
{previewUrl &&
|
||||
(previewUrl.startsWith('http') ||
|
||||
previewUrl.startsWith('blob:') ||
|
||||
previewUrl.startsWith('data:')) ? (
|
||||
<PreviewFileDisplay
|
||||
previewUrl={previewUrl}
|
||||
onChange={onChange}
|
||||
onImageRemoved={onImageRemoved}
|
||||
/>
|
||||
) : (
|
||||
<Flex align="center" gap="xl" justify="between" mt="md">
|
||||
<Flex align="center" gap="sm" minWidth="0">
|
||||
<Icon
|
||||
color="muted"
|
||||
icon={
|
||||
FILE_ICONS[
|
||||
(file instanceof File
|
||||
? file.name.split('.').pop()
|
||||
: '') as keyof typeof FILE_ICONS
|
||||
] || 'tabler:file'
|
||||
}
|
||||
size="1.5rem"
|
||||
/>
|
||||
<Text truncate as="p">
|
||||
{file instanceof File
|
||||
? file.name
|
||||
: file === 'keep'
|
||||
? (preview ?? '<File to be remained unchanged>')
|
||||
: (preview ?? file)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Button
|
||||
icon="tabler:x"
|
||||
p="sm"
|
||||
variant="plain"
|
||||
onClick={() => {
|
||||
setData({ file: null, preview: null })
|
||||
onImageRemoved?.()
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<CompactFileDisplay
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onImageRemoved={onImageRemoved}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
|
||||
import { IconPickerModal } from './IconPickerModal'
|
||||
import { IconPreview } from './components/IconPreview'
|
||||
|
||||
interface IconInputProps {
|
||||
export interface IconInputProps {
|
||||
label?: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
|
||||
@@ -89,10 +89,13 @@ export function ListboxOption({
|
||||
{renderColorAndIcon ? (
|
||||
renderColorAndIcon({ color, icon })
|
||||
) : icon !== undefined ? (
|
||||
<Box
|
||||
<Flex
|
||||
centered
|
||||
aspectRatio="1/1"
|
||||
flexShrink="0"
|
||||
p={convertedColor !== undefined ? 'sm' : undefined}
|
||||
pr={convertedColor === undefined ? 'sm' : undefined}
|
||||
minHeight="0"
|
||||
minWidth="0"
|
||||
px={convertedColor !== undefined ? 'sm' : undefined}
|
||||
r="md"
|
||||
style={
|
||||
convertedColor !== undefined
|
||||
@@ -123,7 +126,7 @@ export function ListboxOption({
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
) : (
|
||||
convertedColor !== undefined && (
|
||||
<Bordered
|
||||
|
||||
@@ -35,8 +35,6 @@ interface ListboxInputProps<T> {
|
||||
customActive?: boolean
|
||||
/** The custom content to display in the listbox button. */
|
||||
renderContent?: (value: T) => React.ReactNode
|
||||
/** Whether to show an action button inside the listbox. */
|
||||
hasActionButton?: boolean
|
||||
/** The i18n namespace for internationalization. See the [main documentation](https://docs.lifeforge.melvinchia.dev) for more details. */
|
||||
namespace?: string
|
||||
/** The error message to display when the field is invalid. */
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Button } from '../Button'
|
||||
import { TextInput } from '../TextInput'
|
||||
import type { InputVariants } from '../shared/types'
|
||||
|
||||
interface NumberInputProps {
|
||||
export interface NumberInputProps {
|
||||
/** The label text displayed above the number input field. Required for 'classic' style. */
|
||||
label?: string
|
||||
label: string
|
||||
/** The icon to display in the input field. Should be a valid icon name from Iconify. Required for 'classic' style. */
|
||||
icon?: string
|
||||
icon: string
|
||||
/** The current numeric value of the input. */
|
||||
value: number
|
||||
/** Callback function called when the input value changes. */
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as styles from './SliderInput.css'
|
||||
import { SliderHeader } from './components/SliderHeader'
|
||||
import { SliderTicks } from './components/SliderTicks'
|
||||
|
||||
interface SliderInputProps extends Omit<BoxProps<'div'>, 'value' | 'onChange'> {
|
||||
export interface SliderInputProps extends Omit<BoxProps<'div'>, 'value' | 'onChange'> {
|
||||
label?: string
|
||||
icon?: string
|
||||
value: number
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useInputLabel } from '../shared/hooks/useInputLabel'
|
||||
import type { InputVariants } from '../shared/types'
|
||||
import { autoFocusableRef } from '../shared/utils/autoFocusableRef'
|
||||
|
||||
type TextAreaInputProps = {
|
||||
export type TextAreaInputProps = {
|
||||
/** The label text displayed above the textarea field. Required for 'classic' style. */
|
||||
label?: string
|
||||
/** The icon to display next to the label. Should be a valid icon name from Iconify. Required for 'classic' style. */
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useInputLabel } from '../shared/hooks/useInputLabel'
|
||||
import type { InputVariants } from '../shared/types'
|
||||
import { TextInputBox } from './components/TextInputBox'
|
||||
|
||||
type TextInputProps = {
|
||||
export type TextInputProps = {
|
||||
label?: string
|
||||
icon?: string
|
||||
placeholder: string
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
|
||||
import { Button } from '@/components/inputs'
|
||||
import { Box } from '@/components/primitives'
|
||||
|
||||
import { ModalHeader } from './ModalHeader'
|
||||
import { ModalHeader } from '.'
|
||||
|
||||
const meta = {
|
||||
argTypes: {
|
||||
actionButtonProps: { control: false },
|
||||
headerActions: { control: false },
|
||||
appendTitle: { control: false },
|
||||
className: { control: 'text' },
|
||||
hasAI: { control: 'boolean' },
|
||||
@@ -49,14 +50,16 @@ function Shell({ children }: { children: React.ReactNode }) {
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icon: 'tabler:plus',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: 'Create Item'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,14 +70,16 @@ export const Default: Story = {
|
||||
export const UntranslatedTitle: Story = {
|
||||
args: {
|
||||
icon: 'tabler:file-unknown',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: 'untranslated_modal_key_xyz'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,14 +90,16 @@ export const WithAIBadge: Story = {
|
||||
args: {
|
||||
hasAI: true,
|
||||
icon: 'tabler:brain',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: 'Smart Suggestions'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,36 +122,43 @@ export const WithAppendTitle: Story = {
|
||||
</span>
|
||||
),
|
||||
icon: 'tabler:tag',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: 'Manage Tags'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `actionButtonProps` renders an additional button in the right-hand action
|
||||
* `headerActions` renders an additional button in the right-hand action
|
||||
* area, to the left of the close button. The most common use-case is a help
|
||||
* button or a secondary action.
|
||||
*/
|
||||
export const WithActionButton: Story = {
|
||||
args: {
|
||||
actionButtonProps: {
|
||||
icon: 'tabler:help',
|
||||
onClick: () => {}
|
||||
},
|
||||
headerActions: (
|
||||
<Button
|
||||
icon="tabler:help"
|
||||
variant="plain"
|
||||
onClick={function () {}}
|
||||
/>
|
||||
),
|
||||
icon: 'tabler:file-export',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: 'Export Data'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,21 +168,26 @@ export const WithActionButton: Story = {
|
||||
*/
|
||||
export const WithActionButtonVariant: Story = {
|
||||
args: {
|
||||
actionButtonProps: {
|
||||
children: 'Preview',
|
||||
icon: 'tabler:eye',
|
||||
onClick: () => {},
|
||||
variant: 'secondary'
|
||||
},
|
||||
headerActions: (
|
||||
<Button
|
||||
icon="tabler:eye"
|
||||
variant="secondary"
|
||||
onClick={function () {}}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
),
|
||||
icon: 'tabler:send',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: 'Publish Post'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,10 +196,13 @@ export const WithActionButtonVariant: Story = {
|
||||
*/
|
||||
export const KitchenSink: Story = {
|
||||
args: {
|
||||
actionButtonProps: {
|
||||
icon: 'tabler:settings',
|
||||
onClick: () => {}
|
||||
},
|
||||
headerActions: (
|
||||
<Button
|
||||
icon="tabler:settings"
|
||||
variant="plain"
|
||||
onClick={function () {}}
|
||||
/>
|
||||
),
|
||||
appendTitle: (
|
||||
<span
|
||||
style={{
|
||||
@@ -199,14 +221,16 @@ export const KitchenSink: Story = {
|
||||
),
|
||||
hasAI: true,
|
||||
icon: 'tabler:sparkles',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: 'AI Content Generator'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,18 +240,20 @@ export const KitchenSink: Story = {
|
||||
export const ReactNodeTitle: Story = {
|
||||
args: {
|
||||
icon: 'tabler:edit',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title: (
|
||||
<span style={{ alignItems: 'center', display: 'flex', gap: '0.5rem' }}>
|
||||
Edit <strong style={{ color: '#4caf50' }}>Project Alpha</strong>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,13 +263,15 @@ export const ReactNodeTitle: Story = {
|
||||
export const LongTitle: Story = {
|
||||
args: {
|
||||
icon: 'tabler:text-size',
|
||||
onClose: () => {},
|
||||
onClose: function () {},
|
||||
title:
|
||||
'This Is An Exceptionally Long Modal Title That Might Overflow If Not Truncated Properly'
|
||||
},
|
||||
render: args => (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
render: function (args) {
|
||||
return (
|
||||
<Shell>
|
||||
<ModalHeader {...args} />
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ import _ from 'lodash'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/inputs'
|
||||
import { Box, Flex, Icon, Text } from '@/components/primitives'
|
||||
|
||||
import { Button } from '../../../../inputs/Button'
|
||||
|
||||
function _ModalHeader({
|
||||
title,
|
||||
icon,
|
||||
@@ -15,7 +14,7 @@ function _ModalHeader({
|
||||
hasAI = false,
|
||||
appendTitle,
|
||||
namespace = 'common.modals',
|
||||
actionButtonProps
|
||||
headerActions
|
||||
}: {
|
||||
title: string | React.ReactNode
|
||||
icon: string
|
||||
@@ -24,7 +23,7 @@ function _ModalHeader({
|
||||
className?: string
|
||||
appendTitle?: React.ReactElement
|
||||
namespace?: string
|
||||
actionButtonProps?: React.ComponentProps<typeof Button>
|
||||
headerActions?: React.ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation(namespace)
|
||||
|
||||
@@ -39,7 +38,7 @@ function _ModalHeader({
|
||||
align="center"
|
||||
className={className}
|
||||
justify="between"
|
||||
mb="md"
|
||||
mb="sm"
|
||||
style={{ gap: '0.75rem' }}
|
||||
>
|
||||
<Text asChild size="xl" weight="semibold">
|
||||
@@ -87,12 +86,7 @@ function _ModalHeader({
|
||||
</Flex>
|
||||
</Text>
|
||||
<Flex align="center" gap="sm">
|
||||
{actionButtonProps && (
|
||||
<Button
|
||||
{...actionButtonProps}
|
||||
variant={actionButtonProps.variant || 'plain'}
|
||||
/>
|
||||
)}
|
||||
{headerActions}
|
||||
<Button
|
||||
icon="tabler:x"
|
||||
style={{ padding: '0.75rem' }}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { useModalStore } from '@/providers'
|
||||
|
||||
import { ModalWrapper } from '../components/ModalWrapper'
|
||||
import { ModalWrapper } from '../ModalWrapper'
|
||||
|
||||
function FinalElement({ index }: { index: number }) {
|
||||
const { stack, close } = useModalStore()
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Flex } from '@/components/primitives'
|
||||
|
||||
import { ModalHeader } from '../../core/components/ModalHeader'
|
||||
import { ModalHeader } from '../ModalHeader'
|
||||
|
||||
export function ViewImageModal({
|
||||
data: { src },
|
||||
@@ -1,394 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import z from 'zod'
|
||||
|
||||
import { Button } from '@/components/inputs'
|
||||
import { useModalStore } from '@/providers'
|
||||
|
||||
import { defineForm } from './formBuilder'
|
||||
import { FormModal } from './index'
|
||||
|
||||
type CuteForm = {
|
||||
name: string
|
||||
age: number
|
||||
color: string
|
||||
icon: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
const meta = {
|
||||
argTypes: {
|
||||
form: {
|
||||
control: false
|
||||
},
|
||||
'ui.actionButton': {
|
||||
control: false,
|
||||
description:
|
||||
'Action button to be displayed at the top right corner besides the close button.',
|
||||
table: {
|
||||
type: {
|
||||
summary: 'React.ComponentProps<typeof Button>'
|
||||
}
|
||||
},
|
||||
type: {
|
||||
required: false,
|
||||
summary: 'React.ComponentProps<typeof Button>'
|
||||
}
|
||||
},
|
||||
'ui.icon': {
|
||||
description:
|
||||
'The icon besides the form title. Must be a valid icon identifier from Iconify in the form of `<icon-set>:<icon-name>`.',
|
||||
type: {
|
||||
required: true,
|
||||
summary: 'string'
|
||||
}
|
||||
},
|
||||
|
||||
'ui.loading': {
|
||||
description:
|
||||
'Whether the form modal is in a loading state. A loading spinner will be shown instead of the form fields.'
|
||||
},
|
||||
'ui.namespace': {
|
||||
description:
|
||||
'The i18n namespace for internationalization. See the [main documentation](https://docs.lifeforge.melvinchia.dev) for more details.'
|
||||
},
|
||||
'ui.onClose': {
|
||||
description:
|
||||
'Callback function triggered when the close button is clicked.',
|
||||
table: {
|
||||
type: {
|
||||
summary: '() => void'
|
||||
}
|
||||
},
|
||||
type: {
|
||||
required: true,
|
||||
summary: '() => void'
|
||||
}
|
||||
},
|
||||
'ui.submitButton': {
|
||||
control: false,
|
||||
description: 'The props for the submit button in the form modal.',
|
||||
table: {
|
||||
type: {
|
||||
summary: "'create' | 'update' | React.ComponentProps<typeof Button>"
|
||||
}
|
||||
},
|
||||
type: {
|
||||
required: true,
|
||||
summary: "'create' | 'update' | React.ComponentProps<typeof Button>"
|
||||
}
|
||||
},
|
||||
'ui.title': {
|
||||
description: 'The title of the form modal.',
|
||||
type: {
|
||||
required: true,
|
||||
summary: 'string'
|
||||
}
|
||||
}
|
||||
} as never,
|
||||
component: FormModal,
|
||||
parameters: {
|
||||
deepControls: { enabled: true }
|
||||
}
|
||||
} satisfies Meta<typeof FormModal>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const MyFormModal = ({ onClose }: { onClose: () => void }) => {
|
||||
const { formProps, formStateStore } = defineForm<CuteForm>({
|
||||
actionButton: {
|
||||
icon: 'tabler:cube',
|
||||
variant: 'plain'
|
||||
},
|
||||
icon: 'tabler:forms',
|
||||
loading: false,
|
||||
namespace: '',
|
||||
onClose,
|
||||
submitButton: 'update',
|
||||
title: 'Form Modal'
|
||||
})
|
||||
.typesMap({
|
||||
age: 'number',
|
||||
color: 'color',
|
||||
confirmPassword: 'text',
|
||||
icon: 'listbox',
|
||||
name: 'text',
|
||||
password: 'text'
|
||||
})
|
||||
.setupFields({
|
||||
age: {
|
||||
icon: 'tabler:number-123',
|
||||
label: 'Age',
|
||||
validator: z
|
||||
.number()
|
||||
.int('Invalid age. Age must be an integer.')
|
||||
.nonnegative('Invalid age. Age must be positive.')
|
||||
},
|
||||
color: {
|
||||
label: 'Color'
|
||||
},
|
||||
confirmPassword: {
|
||||
icon: 'tabler:lock',
|
||||
isPassword: true,
|
||||
label: 'Confirm Password',
|
||||
placeholder: '••••••••',
|
||||
required: true,
|
||||
validator: (value, formState) => {
|
||||
if (value !== formState.password) {
|
||||
return 'Passwords must match'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
},
|
||||
icon: {
|
||||
icon: 'tabler:icons',
|
||||
label: 'Icon',
|
||||
multiple: false,
|
||||
options: formState => [
|
||||
...(formState.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' }
|
||||
],
|
||||
required: true
|
||||
},
|
||||
name: {
|
||||
icon: 'tabler:user',
|
||||
label: 'Name',
|
||||
placeholder: 'John Doe',
|
||||
required: true,
|
||||
validator: z
|
||||
.string()
|
||||
.refine(
|
||||
value => /^[a-zA-Z ]+$/.test(value),
|
||||
'Invalid name. Only alphabetic characters and spaces are allowed.'
|
||||
)
|
||||
},
|
||||
password: {
|
||||
actionButtonProps: {
|
||||
icon: 'tabler:dice',
|
||||
onClick: () => {
|
||||
const randomPassword = Math.random().toString(36).slice(-8)
|
||||
|
||||
formStateStore.setState(() => ({
|
||||
confirmPassword: randomPassword,
|
||||
password: randomPassword
|
||||
}))
|
||||
},
|
||||
variant: 'plain'
|
||||
},
|
||||
icon: 'tabler:lock',
|
||||
isPassword: true,
|
||||
label: 'Password',
|
||||
placeholder: '••••••••',
|
||||
required: true,
|
||||
validator: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters long.')
|
||||
.max(100, 'Password must be at most 100 characters long.')
|
||||
}
|
||||
})
|
||||
.autoFocusField('name')
|
||||
.conditionalFields({
|
||||
// age: data => data.name.trim() !== '',
|
||||
// confirmPassword: data => data.password.trim() !== ''
|
||||
})
|
||||
.initialData({})
|
||||
.onSubmit(async formData => {
|
||||
alert(`Form submitted with data: ${JSON.stringify(formData)}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
alert('Form submitted successfully!')
|
||||
})
|
||||
.build()
|
||||
|
||||
return <FormModal {...formProps} />
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
ui: {
|
||||
icon: 'tabler:forms',
|
||||
loading: false,
|
||||
namespace: '',
|
||||
onClose: () => {},
|
||||
submitButton: 'update',
|
||||
title: 'Form Modal'
|
||||
}
|
||||
} as never,
|
||||
render: () => {
|
||||
const { open } = useModalStore()
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon="tabler:plus"
|
||||
onClick={() => {
|
||||
open(MyFormModal, {})
|
||||
}}
|
||||
>
|
||||
Open Form Modal
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <FormModal {...formProps} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon="tabler:plus"
|
||||
onClick={() => {
|
||||
open(FormWithListboxInputActionButtonOption, {})
|
||||
}}
|
||||
>
|
||||
Open Form Modal
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 <FormModal {...formProps} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon="tabler:plus"
|
||||
onClick={() => {
|
||||
open(FormWithDisabledFieldsAndTooltips, {})
|
||||
}}
|
||||
>
|
||||
Open Form Modal
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<boolean, boolean> & {
|
||||
type: 'checkbox'
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function FormCheckboxInput({
|
||||
field,
|
||||
value,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<CheckboxFieldProps>) {
|
||||
const { t } = useTranslation(namespace)
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="sm" width="100%">
|
||||
<Flex align="center" justify="between" py="sm">
|
||||
<Flex align="center" gap="sm">
|
||||
<Icon icon={field.icon} size="1.5em" />
|
||||
<Text size="lg">
|
||||
{t([
|
||||
['inputs', _.camelCase(field.label), 'label']
|
||||
.filter(e => e)
|
||||
.join('.'),
|
||||
['inputs', _.camelCase(field.label)].filter(e => e).join('.')
|
||||
])}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Switch
|
||||
disabled={field.disabled}
|
||||
value={value}
|
||||
onChange={() => {
|
||||
handleChange(!value)
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
{field.errorMsg && (
|
||||
<Text color="dangerous" size="sm">
|
||||
{field.errorMsg}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ColorInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type ColorFieldProps = BaseFieldProps<string, string, true> & {
|
||||
type: 'color'
|
||||
}
|
||||
|
||||
export function FormColorInput({
|
||||
field,
|
||||
value,
|
||||
autoFocus,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<ColorFieldProps>) {
|
||||
return (
|
||||
<ColorInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
errorMsg={field.errorMsg}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { CurrencyInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type CurrencyFieldProps = BaseFieldProps<number, number, true> & {
|
||||
type: 'currency'
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function FormCurrencyInput({
|
||||
field,
|
||||
value,
|
||||
autoFocus,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<CurrencyFieldProps>) {
|
||||
return (
|
||||
<CurrencyInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
errorMsg={field.errorMsg}
|
||||
icon={field.icon}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
placeholder="0.00"
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { DateInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type DateFieldProps = BaseFieldProps<Date | null, string, true> & {
|
||||
type: 'datetime'
|
||||
icon: string
|
||||
hasTime?: boolean
|
||||
}
|
||||
|
||||
export function FormDateInput({
|
||||
field,
|
||||
value,
|
||||
autoFocus,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<DateFieldProps>) {
|
||||
return (
|
||||
<DateInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
hasTime={field.hasTime}
|
||||
icon={field.icon}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type FileData, FileInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type FileFieldProps<TOptional extends boolean = false> = BaseFieldProps<
|
||||
FileData,
|
||||
string | File
|
||||
> & {
|
||||
type: 'file'
|
||||
icon: string
|
||||
optional: TOptional
|
||||
onFileRemoved?: () => void
|
||||
enablePixabay?: boolean
|
||||
enableUrl?: boolean
|
||||
enableAIImageGeneration?: boolean
|
||||
defaultImageGenerationPrompt?: string
|
||||
acceptedMimeTypes?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export function FormFileInput({
|
||||
field,
|
||||
value,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<FileFieldProps>) {
|
||||
return (
|
||||
<FileInput
|
||||
acceptedMimeTypes={field.acceptedMimeTypes}
|
||||
defaultAIPrompt={field.defaultImageGenerationPrompt}
|
||||
disabled={field.disabled}
|
||||
enableAI={field.enableAIImageGeneration}
|
||||
enablePixabay={field.enablePixabay}
|
||||
enableUrl={field.enableUrl}
|
||||
file={value.file}
|
||||
icon={field.icon}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
preview={value.preview}
|
||||
required={field.required}
|
||||
setData={handleChange}
|
||||
onImageRemoved={() => {
|
||||
handleChange({
|
||||
file: 'removed',
|
||||
preview: null
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { IconInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type IconFieldProps = BaseFieldProps<string, string, true> & {
|
||||
type: 'icon'
|
||||
}
|
||||
|
||||
export function FormIconInput({
|
||||
field,
|
||||
value,
|
||||
autoFocus,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<IconFieldProps>) {
|
||||
return (
|
||||
<IconInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
errorMsg={field.errorMsg}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 <Icon icon={icon} style={{ color }} />
|
||||
}
|
||||
|
||||
if (!color) {
|
||||
return <Icon icon={icon ?? ''} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="inline-block"
|
||||
flexShrink="0"
|
||||
height="0.75em"
|
||||
r="full"
|
||||
style={{
|
||||
backgroundColor: color
|
||||
}}
|
||||
width="0.75em"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Flex align="center" gap="md" wrap="wrap">
|
||||
{value.length > 0 &&
|
||||
value.map((item: string, i: number) => (
|
||||
<Fragment key={item}>
|
||||
<Flex align="center" gap="xs">
|
||||
<Icon
|
||||
icon={options.find(l => l.value === item)?.icon ?? ''}
|
||||
style={{
|
||||
color: options.find(l => l.value === item)?.color
|
||||
}}
|
||||
/>
|
||||
<Text truncate>
|
||||
{options.find(l => l.value === item)?.text ?? 'None'}
|
||||
</Text>
|
||||
</Flex>
|
||||
{i !== value.length - 1 && (
|
||||
<Icon icon="tabler:circle-filled" size="0.25em" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const targetOption = options.find(l => l.value === value)
|
||||
|
||||
if (!targetOption) {
|
||||
return <Text>None</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex align="center" gap="sm">
|
||||
<OptionColorAndIcon color={targetOption.color} icon={targetOption.icon} />
|
||||
<Text truncate>
|
||||
{options.find(l => l.value === value)?.text ?? 'None'}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export function FormListboxInput({
|
||||
field,
|
||||
value,
|
||||
namespace,
|
||||
handleChange,
|
||||
options
|
||||
}: FormInputProps<ListboxFieldProps>) {
|
||||
if (!options) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ListboxInput
|
||||
renderContent={() => (
|
||||
<ListboxButtonContent field={field} options={options} value={value} />
|
||||
)}
|
||||
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 }) => (
|
||||
<ListboxOption
|
||||
key={v}
|
||||
color={color}
|
||||
icon={icon}
|
||||
label={text}
|
||||
selected={JSON.stringify(v) === JSON.stringify(value)}
|
||||
value={v}
|
||||
/>
|
||||
))}
|
||||
{field.actionButtonOption && (
|
||||
<ListboxOption
|
||||
icon={field.actionButtonOption.icon}
|
||||
label={field.actionButtonOption.text}
|
||||
selected={false}
|
||||
value={null}
|
||||
onClick={field.actionButtonOption.onClick}
|
||||
/>
|
||||
)}
|
||||
</ListboxInput>
|
||||
)
|
||||
}
|
||||
@@ -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<LocationFieldProps>) {
|
||||
return (
|
||||
<LocationInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={value => handleChange(value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NumberInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type NumberFieldProps = BaseFieldProps<number, number, true> & {
|
||||
type: 'number'
|
||||
icon: string
|
||||
}
|
||||
|
||||
export function FormNumberInput({
|
||||
field,
|
||||
value,
|
||||
autoFocus,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<NumberFieldProps>) {
|
||||
return (
|
||||
<NumberInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
errorMsg={field.errorMsg}
|
||||
icon={field.icon}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { RRuleInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type RRuleFieldProps = BaseFieldProps<string, string> & {
|
||||
type: 'rrule'
|
||||
hasDuration?: boolean
|
||||
}
|
||||
|
||||
export function FormRRuleInput({
|
||||
field,
|
||||
value,
|
||||
handleChange
|
||||
}: FormInputProps<RRuleFieldProps>) {
|
||||
return (
|
||||
<RRuleInput
|
||||
hasDuration={!!field.hasDuration}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { SliderInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type SliderFieldProps = BaseFieldProps<number, number, true> & {
|
||||
type: 'slider'
|
||||
icon?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}
|
||||
|
||||
export function FormSliderInput({
|
||||
field,
|
||||
value,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<SliderFieldProps>) {
|
||||
return (
|
||||
<SliderInput
|
||||
disabled={field.disabled}
|
||||
icon={field.icon}
|
||||
label={field.label}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
namespace={namespace}
|
||||
required={field.required}
|
||||
step={field.step}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { TextAreaInput } from '@/components/inputs'
|
||||
|
||||
import type {
|
||||
BaseFieldProps,
|
||||
FormInputProps
|
||||
} from '../../../typescript/form.types'
|
||||
|
||||
export type TextAreaFieldProps = BaseFieldProps<string, string, true> & {
|
||||
type: 'textarea'
|
||||
icon: string
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
export function FormTextAreaInput({
|
||||
field,
|
||||
value,
|
||||
autoFocus,
|
||||
namespace,
|
||||
handleChange
|
||||
}: FormInputProps<TextAreaFieldProps>) {
|
||||
return (
|
||||
<TextAreaInput
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
errorMsg={field.errorMsg}
|
||||
icon={field.icon}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string, true> & {
|
||||
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<TextFieldProps>) {
|
||||
const { open } = useModalStore()
|
||||
|
||||
const openQRScanner = () => {
|
||||
open(QRCodeScanner, {
|
||||
onScanned: data => {
|
||||
handleChange(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextInput
|
||||
actionButtonProps={
|
||||
field.actionButtonProps
|
||||
? field.actionButtonProps
|
||||
: field.qrScanner
|
||||
? {
|
||||
icon: 'tabler:qrcode',
|
||||
onClick: openQRScanner
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
autoFocus={autoFocus}
|
||||
disabled={field.disabled}
|
||||
errorMsg={field.errorMsg}
|
||||
icon={field.icon}
|
||||
isPassword={field.isPassword}
|
||||
label={field.label}
|
||||
namespace={namespace}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<FormInputProps<any>>
|
||||
> = {
|
||||
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 (
|
||||
<Flex align="center" flex="1" gap="md">
|
||||
<FormComponent
|
||||
key={id}
|
||||
autoFocus={autoFocus}
|
||||
field={{ ...field, errorMsg }}
|
||||
handleChange={onFieldChange}
|
||||
namespace={namespace}
|
||||
options={options}
|
||||
value={value}
|
||||
/>
|
||||
{field.disabled && field.disabledReason && (
|
||||
<Tooltip
|
||||
icon="tabler:info-circle"
|
||||
id={`tooltip-disabled-reason-${field.label}`}
|
||||
>
|
||||
{field.disabledReason}
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
},
|
||||
(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<T extends FormState>({
|
||||
fields,
|
||||
autoFocusableFieldId,
|
||||
conditionalFields,
|
||||
data,
|
||||
setData,
|
||||
errorMsgs,
|
||||
removeErrorMsg,
|
||||
namespace
|
||||
}: {
|
||||
fields: Record<string, FormFieldPropsUnion>
|
||||
autoFocusableFieldId?: string
|
||||
conditionalFields?: {
|
||||
[K in keyof T]?: (data: T) => boolean
|
||||
}
|
||||
data: T
|
||||
setData: React.Dispatch<React.SetStateAction<T>>
|
||||
errorMsgs: Record<string, string | undefined>
|
||||
removeErrorMsg: (fieldId: string) => void
|
||||
namespace?: string
|
||||
}) {
|
||||
const changeHandlers = useMemo(() => {
|
||||
const handlers: Record<string, (value: any) => void> = {}
|
||||
|
||||
Object.keys(fields).forEach(id => {
|
||||
handlers[id] = (value: any) => {
|
||||
removeErrorMsg(id)
|
||||
setData(prev => ({ ...prev, [id]: value }))
|
||||
}
|
||||
})
|
||||
|
||||
return handlers
|
||||
}, [fields])
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="md">
|
||||
{Object.entries(fields as Record<string, FormFieldPropsUnion>).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 (
|
||||
<MemoizedFormField
|
||||
key={id}
|
||||
autoFocus={autoFocusableFieldId === id}
|
||||
errorMsg={errorMsg}
|
||||
field={field}
|
||||
id={id}
|
||||
namespace={namespace}
|
||||
options={options}
|
||||
value={value}
|
||||
onFieldChange={changeHandlers[id] || (() => {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import _ from 'lodash'
|
||||
|
||||
import { Button } from '@/components/inputs'
|
||||
|
||||
export function SubmitButton({
|
||||
submitButton,
|
||||
submitLoading,
|
||||
onSubmitButtonClick
|
||||
}: {
|
||||
submitButton: 'create' | 'update' | React.ComponentProps<typeof Button>
|
||||
submitLoading: boolean
|
||||
onSubmitButtonClick: () => Promise<void>
|
||||
}) {
|
||||
if (typeof submitButton === 'string') {
|
||||
return (
|
||||
<Button
|
||||
icon={submitButton === 'create' ? 'tabler:plus' : 'tabler:pencil'}
|
||||
loading={submitLoading}
|
||||
mt="lg"
|
||||
width="100%"
|
||||
onClick={onSubmitButtonClick}
|
||||
>
|
||||
{_.upperFirst(submitButton)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitButton) {
|
||||
return (
|
||||
<Button
|
||||
mt="lg"
|
||||
width="100%"
|
||||
{...submitButton}
|
||||
loading={submitLoading}
|
||||
onClick={onSubmitButtonClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <></>
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useMemo } from 'react'
|
||||
import { type StoreApi, type UseBoundStore, create } from 'zustand'
|
||||
|
||||
import type { FormModal } from '.'
|
||||
import type {
|
||||
FieldsConfig,
|
||||
FormState,
|
||||
InferAutoFocusableFieldIds,
|
||||
InferFormFinalState,
|
||||
InferFormState,
|
||||
MatchFieldByFormDataType
|
||||
} from './typescript/form.types'
|
||||
import { getInitialData } from './utils/formUtils'
|
||||
|
||||
/**
|
||||
* Since the inferred body input data from `forgeAPI` might be complex unions,
|
||||
* we need a way to flatten these unions for easier type inference.
|
||||
*
|
||||
* @template T - The union type to flatten
|
||||
*/
|
||||
type FlattenUnion<T> = {
|
||||
[K in T extends any ? keyof T : never]: T extends { [k in K]?: any }
|
||||
? T[K]
|
||||
: never
|
||||
}
|
||||
|
||||
/**
|
||||
* A type-safe form builder class that provides a fluent API for constructing FormModal configurations.
|
||||
*
|
||||
* This class uses the builder pattern to allow step-by-step configuration of form properties
|
||||
* with full TypeScript type safety and inference. Each method returns a new instance with
|
||||
* updated type parameters, ensuring that the form configuration is valid at compile time.
|
||||
*
|
||||
* @template TFormState - The base form state type defining field names and data types
|
||||
* @template TFieldType - Mapping of field names to their corresponding field types
|
||||
* @template TFieldsConfig - Configuration object for form fields (initially undefined)
|
||||
* @template TFinalFields - Final processed field configuration with type information
|
||||
* @template TInitialData - Initial form data type (initially undefined)
|
||||
* @template TOnSubmit - Submit handler function type (initially undefined)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Define form structure
|
||||
* type UserForm = {
|
||||
* name: string
|
||||
* email: string
|
||||
* age: number
|
||||
* }
|
||||
*
|
||||
* // Build form configuration
|
||||
* const formConfig = defineForm<UserForm>({
|
||||
* title: 'Create User',
|
||||
* icon: 'user-plus',
|
||||
* onClose: () => setModalOpen(false),
|
||||
* submitButton: 'tabler:plus'
|
||||
* })
|
||||
* .typesMap({ name: 'text', email: 'email', age: 'number' })
|
||||
* .setupFields({
|
||||
* name: { label: 'Full Name', required: true },
|
||||
* email: { label: 'Email Address', required: true },
|
||||
* age: { label: 'Age', min: 0, max: 120 }
|
||||
* })
|
||||
* .initialData({ name: '', email: '', age: 0 })
|
||||
* .onSubmit(async (data) => {
|
||||
* console.log('Submitting:', data)
|
||||
* })
|
||||
* .build()
|
||||
* ```
|
||||
*/
|
||||
export class FormBuilder<
|
||||
TFormState extends FormState,
|
||||
TFieldType extends {
|
||||
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
|
||||
} = {
|
||||
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
|
||||
},
|
||||
TFieldsConfig = undefined,
|
||||
TFinalFields = undefined,
|
||||
TInitialData = undefined,
|
||||
TOnSubmit = undefined
|
||||
> {
|
||||
/** UI configuration for the form modal */
|
||||
private readonly uiConfig?: React.ComponentProps<typeof FormModal>['ui']
|
||||
/** Mapping of field names to their types */
|
||||
private readonly fieldType?: TFieldType
|
||||
/** Raw field configuration object */
|
||||
private readonly fields?: TFieldsConfig
|
||||
/** Field ID to be autofocused */
|
||||
private readonly _autoFocusableFieldId?: keyof TFormState
|
||||
private readonly _conditionalFields?: {
|
||||
[K in keyof TFormState]?: (data: TFormState) => boolean
|
||||
}
|
||||
/** Final processed field configuration with types */
|
||||
private readonly finalFields?: TFinalFields
|
||||
/** Initial data for form fields */
|
||||
private readonly _initialData?: TInitialData
|
||||
/** Form submission handler */
|
||||
private readonly _onSubmit?: TOnSubmit
|
||||
|
||||
/**
|
||||
* Creates a new FormBuilder instance. Generally not called directly - use `defineForm()` instead.
|
||||
*
|
||||
* @param opts - Optional configuration object containing all builder properties
|
||||
*/
|
||||
constructor(opts?: {
|
||||
uiConfig?: React.ComponentProps<typeof FormModal>['ui']
|
||||
fieldType?: TFieldType
|
||||
fields?: TFieldsConfig
|
||||
autoFocusableFieldId?: keyof TFormState
|
||||
finalFields?: TFinalFields
|
||||
initialData?: TInitialData
|
||||
onSubmit?: TOnSubmit
|
||||
conditionalFields?: {
|
||||
[K in keyof TFormState]?: (data: TFormState) => boolean
|
||||
}
|
||||
}) {
|
||||
if (opts) {
|
||||
this.uiConfig = opts.uiConfig
|
||||
this.fieldType = opts.fieldType
|
||||
this.fields = opts.fields
|
||||
this._autoFocusableFieldId = opts.autoFocusableFieldId
|
||||
this.finalFields = opts.finalFields
|
||||
this._initialData = opts.initialData
|
||||
this._onSubmit = opts.onSubmit
|
||||
this._conditionalFields = opts.conditionalFields
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps form field names to their corresponding input types.
|
||||
* This step is required and defines what type of input component will be rendered for each field.
|
||||
*
|
||||
* @param fieldType - Object mapping field names to their input types.
|
||||
* IntelliSense for the `setupFields()` function will provide suggestions
|
||||
* based on this mapping. Available field types for each form field are inferred
|
||||
* from the form state type.
|
||||
* @returns A new FormBuilder instance with the field type mapping applied
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* .typesMap({
|
||||
* name: 'text',
|
||||
* email: 'email',
|
||||
* age: 'number',
|
||||
* country: 'select'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
typesMap<
|
||||
TFieldType2 extends {
|
||||
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
|
||||
}
|
||||
>(
|
||||
fieldType: TFieldType2
|
||||
): FormBuilder<
|
||||
TFormState,
|
||||
TFieldType2,
|
||||
TFieldsConfig,
|
||||
TFinalFields,
|
||||
TInitialData,
|
||||
TOnSubmit
|
||||
> {
|
||||
return new FormBuilder({
|
||||
...this,
|
||||
fieldType
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the individual form fields with their properties and validation rules.
|
||||
* This method processes the field configuration and merges it with the previously defined field types.
|
||||
* Available config options for each field is inferred from the corresponding field type mapping defined
|
||||
* in the `typesMap()` step.
|
||||
*
|
||||
* @param fields - Configuration object where keys are field names and values contain field properties
|
||||
* @returns A new FormBuilder instance with processed field configuration
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* .setupFields({
|
||||
* name: {
|
||||
* label: 'Full Name',
|
||||
* placeholder: 'Enter your full name',
|
||||
* required: true
|
||||
* },
|
||||
* email: {
|
||||
* label: 'Email Address',
|
||||
* placeholder: 'you@example.com',
|
||||
* required: true
|
||||
* },
|
||||
* age: {
|
||||
* label: 'Age',
|
||||
* required: true
|
||||
* }
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
setupFields<TFields extends FieldsConfig<TFormState, TFieldType>>(
|
||||
fields: TFields
|
||||
): FormBuilder<
|
||||
TFormState,
|
||||
TFieldType,
|
||||
TFields,
|
||||
{
|
||||
[K in keyof TFields]: TFields[K] & {
|
||||
type: TFieldType[K extends keyof TFieldType ? K : never]
|
||||
}
|
||||
},
|
||||
TInitialData,
|
||||
TOnSubmit
|
||||
> {
|
||||
// Merge field types into field configurations
|
||||
for (const key in fields) {
|
||||
;(fields[key] as any).type = (this.fieldType as any)[key]
|
||||
}
|
||||
type TFinalFields2 = {
|
||||
[K in keyof TFields]: TFields[K] & {
|
||||
type: TFieldType[K extends keyof TFieldType ? K : never]
|
||||
}
|
||||
}
|
||||
|
||||
return new FormBuilder({
|
||||
...this,
|
||||
fields,
|
||||
finalFields: fields as unknown as TFinalFields2
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the field that should be automatically focused when the form is opened.
|
||||
* This is useful for improving user experience by focusing on the most relevant input.
|
||||
*
|
||||
* @param fieldId - The ID of the field to focus on
|
||||
* @returns A new FormBuilder instance with the auto-focus field set
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* .autoFocusField('name')
|
||||
* ```
|
||||
*/
|
||||
autoFocusField(
|
||||
fieldId: InferAutoFocusableFieldIds<TFieldType>
|
||||
): FormBuilder<
|
||||
TFormState,
|
||||
TFieldType,
|
||||
TFieldsConfig,
|
||||
TFinalFields,
|
||||
TInitialData,
|
||||
TOnSubmit
|
||||
> {
|
||||
return new FormBuilder({
|
||||
uiConfig: this.uiConfig,
|
||||
fieldType: this.fieldType,
|
||||
fields: this.fields,
|
||||
autoFocusableFieldId: fieldId as string,
|
||||
conditionalFields: this._conditionalFields,
|
||||
finalFields: this.finalFields,
|
||||
initialData: this._initialData,
|
||||
onSubmit: this._onSubmit
|
||||
})
|
||||
}
|
||||
|
||||
conditionalFields(fields: {
|
||||
[K in keyof TFormState]?: (data: TFormState) => boolean
|
||||
}): FormBuilder<
|
||||
TFormState,
|
||||
TFieldType,
|
||||
TFieldsConfig,
|
||||
TFinalFields,
|
||||
TInitialData,
|
||||
TOnSubmit
|
||||
> {
|
||||
return new FormBuilder({
|
||||
uiConfig: this.uiConfig,
|
||||
fieldType: this.fieldType,
|
||||
fields: this.fields,
|
||||
autoFocusableFieldId: this._autoFocusableFieldId,
|
||||
conditionalFields: fields,
|
||||
finalFields: this.finalFields,
|
||||
initialData: this._initialData,
|
||||
onSubmit: this._onSubmit
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the initial data for the form fields. This is optional and allows pre-populating fields.
|
||||
*
|
||||
* @param initialData - Partial object containing initial values for form fields
|
||||
* @returns A new FormBuilder instance with initial data configured
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* .initialData({
|
||||
* name: 'John Doe',
|
||||
* email: 'john@example.com'
|
||||
* // age field will remain empty
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
initialData(
|
||||
initialData?: Partial<
|
||||
InferFormState<NonNullable<TFieldType>, NonNullable<TFinalFields>>
|
||||
>
|
||||
): FormBuilder<
|
||||
TFormState,
|
||||
TFieldType,
|
||||
TFieldsConfig,
|
||||
TFinalFields,
|
||||
typeof initialData,
|
||||
TOnSubmit
|
||||
> {
|
||||
return new FormBuilder({
|
||||
uiConfig: this.uiConfig,
|
||||
fieldType: this.fieldType,
|
||||
fields: this.fields,
|
||||
finalFields: this.finalFields,
|
||||
autoFocusableFieldId: this._autoFocusableFieldId,
|
||||
conditionalFields: this._conditionalFields,
|
||||
onSubmit: this._onSubmit,
|
||||
initialData
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the form submission handler. This is a required step.
|
||||
* The callback receives the final form data with proper typing.
|
||||
*
|
||||
* @param callback - Async function that handles form submission
|
||||
* @returns A new FormBuilder instance with the submit handler configured
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* .onSubmit(async (data) => {
|
||||
* // Do some preprocessing if needed before submission
|
||||
*
|
||||
* await mutation.mutateAsync(data)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
onSubmit(
|
||||
callback: (
|
||||
data: InferFormFinalState<
|
||||
NonNullable<TFieldType>,
|
||||
NonNullable<TFinalFields>
|
||||
>
|
||||
) => Promise<void>
|
||||
): FormBuilder<
|
||||
TFormState,
|
||||
TFieldType,
|
||||
TFieldsConfig,
|
||||
TFinalFields,
|
||||
TInitialData,
|
||||
typeof callback
|
||||
> {
|
||||
return new FormBuilder({
|
||||
uiConfig: this.uiConfig,
|
||||
fieldType: this.fieldType,
|
||||
fields: this.fields,
|
||||
finalFields: this.finalFields,
|
||||
autoFocusableFieldId: this._autoFocusableFieldId,
|
||||
conditionalFields: this._conditionalFields,
|
||||
initialData: this._initialData,
|
||||
onSubmit: callback
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the final FormModal configuration object.
|
||||
* This method validates that all required steps have been completed and returns
|
||||
* a configuration object that can be passed to the FormModal component.
|
||||
*
|
||||
* @returns Configuration object for FormModal component
|
||||
* @throws Error if required configuration steps are missing
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const formConfig = defineForm<UserForm>({
|
||||
* title: 'Create User',
|
||||
* icon: 'user-plus',
|
||||
* onClose: () => setModalOpen(false),
|
||||
* submitButton: 'tabler:plus'
|
||||
* })
|
||||
* .typesMap({ name: 'text', email: 'email' })
|
||||
* .setupFields({ name: { label: 'Name' }, email: { label: 'Email' } })
|
||||
* .onSubmit(async (data) => { await saveUser(data) })
|
||||
* .build()
|
||||
*
|
||||
* // Use with FormModal
|
||||
* <FormModal {...formConfig} isOpen={isOpen} onClose={onClose} />
|
||||
* ```
|
||||
*/
|
||||
build(): {
|
||||
formProps: React.ComponentProps<typeof FormModal>
|
||||
formStateStore: UseBoundStore<StoreApi<TFormState>>
|
||||
} {
|
||||
const {
|
||||
uiConfig,
|
||||
fieldType,
|
||||
finalFields,
|
||||
_initialData,
|
||||
_onSubmit,
|
||||
_conditionalFields
|
||||
} = this
|
||||
|
||||
// Validate that all required configuration steps are complete
|
||||
if (!uiConfig || !fieldType || !finalFields || !_onSubmit) {
|
||||
throw new Error('FormBuilder: Some required steps not complete')
|
||||
}
|
||||
|
||||
const formStateStore = create(
|
||||
() =>
|
||||
getInitialData(
|
||||
fieldType,
|
||||
finalFields,
|
||||
_initialData as any
|
||||
) as TFormState
|
||||
)
|
||||
|
||||
const memoizedValue = useMemo(() => {
|
||||
return {
|
||||
formProps: {
|
||||
form: {
|
||||
fieldTypes: fieldType,
|
||||
fields: finalFields,
|
||||
autoFocusableFieldId: this._autoFocusableFieldId as string,
|
||||
conditionalFields: _conditionalFields as any,
|
||||
onSubmit: _onSubmit as unknown as (
|
||||
data: InferFormFinalState<any, any>
|
||||
) => Promise<void>
|
||||
},
|
||||
ui: uiConfig,
|
||||
dataStore: formStateStore
|
||||
},
|
||||
formStateStore
|
||||
}
|
||||
}, [formStateStore])
|
||||
|
||||
return memoizedValue
|
||||
}
|
||||
}
|
||||
|
||||
export class FormBuilderWithoutTypesMap<TFormState extends FormState> {
|
||||
constructor(private uiConfig: React.ComponentProps<typeof FormModal>['ui']) {}
|
||||
|
||||
typesMap<
|
||||
TFieldType2 extends {
|
||||
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
|
||||
}
|
||||
>(fieldType: TFieldType2): FormBuilder<TFormState, TFieldType2> {
|
||||
return new FormBuilder({
|
||||
...this,
|
||||
uiConfig: this.uiConfig,
|
||||
fieldType
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a new type-safe form builder instance.
|
||||
*
|
||||
* This is the entry point for building forms with full TypeScript type safety.
|
||||
* The generic parameter defines the shape of your form data, and the builder
|
||||
* will ensure that all subsequent configuration steps are properly typed.
|
||||
*
|
||||
* @template T - The form state type defining field names and their data types
|
||||
* @returns A new FormBuilder instance ready for configuration
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Define the shape of your form data
|
||||
* type CreateUserForm = {
|
||||
* name: string
|
||||
* email: string
|
||||
* age: number
|
||||
* isActive: boolean
|
||||
* }
|
||||
*
|
||||
* // Create a type-safe form builder
|
||||
* const userFormConfig = defineForm<CreateUserForm>({
|
||||
* title: 'Create New User',
|
||||
* icon: 'user-plus',
|
||||
* onClose: () => setModalOpen(false),
|
||||
* submitButton: 'create'
|
||||
* })
|
||||
* .typesMap({
|
||||
* name: 'text',
|
||||
* email: 'email',
|
||||
* age: 'number',
|
||||
* isActive: 'checkbox'
|
||||
* })
|
||||
* .setupFields({
|
||||
* name: {
|
||||
* label: 'Full Name',
|
||||
* placeholder: 'Enter full name',
|
||||
* required: true
|
||||
* },
|
||||
* email: {
|
||||
* label: 'Email Address',
|
||||
* placeholder: 'user@example.com',
|
||||
* required: true
|
||||
* },
|
||||
* age: {
|
||||
* label: 'Age',
|
||||
* required: true
|
||||
* },
|
||||
* isActive: {
|
||||
* label: 'Active User'
|
||||
* }
|
||||
* })
|
||||
* .initialData({
|
||||
* name: '',
|
||||
* email: '',
|
||||
* age: 0,
|
||||
* isActive: true
|
||||
* })
|
||||
* .onSubmit(async (data) => {
|
||||
* // data is fully typed as CreateUserForm
|
||||
* await createUser(data)
|
||||
* })
|
||||
* .build()
|
||||
*
|
||||
* // Use with FormModal component
|
||||
* function UserCreationModal({ isOpen, onClose }: Props) {
|
||||
* return (
|
||||
* <FormModal
|
||||
* {...userFormConfig}
|
||||
* isOpen={isOpen}
|
||||
* onClose={onClose}
|
||||
* />
|
||||
* )
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function defineForm<T extends FormState>(
|
||||
uiConfig: React.ComponentProps<typeof FormModal>['ui']
|
||||
) {
|
||||
return new FormBuilderWithoutTypesMap<FlattenUnion<T>>(uiConfig)
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { loadIcon } from '@iconify/react'
|
||||
import { stringToIcon, validateIconName } from '@iconify/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import _ from 'lodash'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ZodType } from 'zod'
|
||||
import type { StoreApi, UseBoundStore } from 'zustand'
|
||||
|
||||
import { usePromiseLoading } from '@lifeforge/shared'
|
||||
|
||||
import { LoadingScreen } from '@/components/feedback'
|
||||
import { Flex } from '@/components/primitives'
|
||||
|
||||
import { type Button } from '../../../../inputs/Button'
|
||||
import { ModalHeader } from '../../core/components/ModalHeader'
|
||||
import { FormInputs } from './components/FormInputs'
|
||||
import { SubmitButton } from './components/SubmitButton'
|
||||
import type {
|
||||
FormFieldPropsUnion,
|
||||
FormState,
|
||||
InferFormFinalState,
|
||||
InferFormState
|
||||
} from './typescript/form.types'
|
||||
import { checkEmpty } from './utils/formUtils'
|
||||
|
||||
function validateField(
|
||||
validator: ((value: any, formState: FormState) => boolean | string) | ZodType,
|
||||
value: any,
|
||||
formState: FormState
|
||||
) {
|
||||
if (typeof validator === 'function') {
|
||||
return validator(value, formState)
|
||||
}
|
||||
|
||||
const result = validator.safeParse(value)
|
||||
|
||||
return result.success ? true : result.error.issues[0].message
|
||||
}
|
||||
|
||||
export function FormModal({
|
||||
form: {
|
||||
fields,
|
||||
fieldTypes,
|
||||
autoFocusableFieldId,
|
||||
onSubmit,
|
||||
onChange,
|
||||
conditionalFields
|
||||
},
|
||||
ui: {
|
||||
title,
|
||||
icon,
|
||||
namespace,
|
||||
loading = false,
|
||||
onClose,
|
||||
submitButton,
|
||||
actionButton
|
||||
},
|
||||
dataStore
|
||||
}: {
|
||||
/** Form data and field configs. See the [main documentation](https://docs.lifeforge.dev/developer-guide/forms) for more details. */
|
||||
form: {
|
||||
fields: Record<string, FormFieldPropsUnion>
|
||||
fieldTypes: Record<string, FormFieldPropsUnion['type']>
|
||||
autoFocusableFieldId?: string
|
||||
onSubmit: (
|
||||
data: InferFormFinalState<typeof fieldTypes, typeof fields>
|
||||
) => Promise<void>
|
||||
onChange?: (data: InferFormState<typeof fieldTypes, typeof fields>) => void
|
||||
conditionalFields?: {
|
||||
[K in keyof InferFormState<typeof fieldTypes, typeof fields>]?: (
|
||||
data: InferFormState<typeof fieldTypes, typeof fields>
|
||||
) => boolean
|
||||
}
|
||||
}
|
||||
ui: {
|
||||
title: string | React.ReactNode
|
||||
icon: string
|
||||
onClose: () => void
|
||||
namespace?: string
|
||||
loading?: boolean
|
||||
submitButton: 'create' | 'update' | React.ComponentProps<typeof Button>
|
||||
actionButton?: Omit<React.ComponentProps<typeof Button>, 'onClick'> & {
|
||||
onClick?: (
|
||||
data: InferFormState<typeof fieldTypes, typeof fields>,
|
||||
setData: React.Dispatch<
|
||||
React.SetStateAction<InferFormState<typeof fieldTypes, typeof fields>>
|
||||
>
|
||||
) => void
|
||||
}
|
||||
}
|
||||
dataStore: UseBoundStore<
|
||||
StoreApi<InferFormState<typeof fieldTypes, typeof fields>>
|
||||
>
|
||||
}) {
|
||||
const { t } = useTranslation('common.misc')
|
||||
|
||||
const [data, setData] = useState(() => dataStore.getState())
|
||||
|
||||
useEffect(() => {
|
||||
dataStore.setState(data)
|
||||
|
||||
const unsubscribe = dataStore.subscribe(data => {
|
||||
setData(data)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [dataStore])
|
||||
|
||||
const [errorMsgs, setErrorMsgs] = useState<
|
||||
Record<string, string | undefined>
|
||||
>({})
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
const visibleFields = Object.entries(fields).filter(field => {
|
||||
// Explicitly defined hidden state takes precedence
|
||||
if (field[1].hidden) {
|
||||
return false
|
||||
}
|
||||
|
||||
const conditionalCall = conditionalFields?.[field[0]]?.(data)
|
||||
|
||||
if (typeof conditionalCall === 'boolean') {
|
||||
return conditionalCall
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Transform field values based on their types
|
||||
const finalData = Object.fromEntries(
|
||||
Object.entries(data).map(([key, value]) => {
|
||||
const fieldType = fields[key]?.type
|
||||
|
||||
if (!fieldType) {
|
||||
return [key, value]
|
||||
}
|
||||
|
||||
let finalValue: unknown = value
|
||||
|
||||
switch (fieldType) {
|
||||
case 'datetime':
|
||||
finalValue = value
|
||||
? dayjs(value as never).format('YYYY-MM-DDTHH:mm:ssZ')
|
||||
: undefined
|
||||
break
|
||||
case 'location':
|
||||
finalValue = value ?? undefined
|
||||
break
|
||||
case 'currency':
|
||||
case 'number':
|
||||
finalValue = Number(value)
|
||||
break
|
||||
case 'file':
|
||||
finalValue = (value as any).file ?? undefined
|
||||
break
|
||||
case 'checkbox':
|
||||
finalValue = Boolean(value)
|
||||
break
|
||||
|
||||
case 'listbox':
|
||||
const options =
|
||||
typeof fields[key].options === 'function'
|
||||
? fields[key].options(data)
|
||||
: fields[key].options
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
finalValue = value.filter(v =>
|
||||
options.find(option => option.value === v)
|
||||
)
|
||||
} else {
|
||||
finalValue = options.find(option => option.value === value)
|
||||
? value
|
||||
: undefined
|
||||
}
|
||||
break
|
||||
default:
|
||||
finalValue = value
|
||||
}
|
||||
|
||||
return [key, finalValue]
|
||||
})
|
||||
)
|
||||
|
||||
const validationResults: Record<string, string | undefined> = {}
|
||||
|
||||
for (const [key, field] of visibleFields) {
|
||||
const value = finalData[key]
|
||||
|
||||
const isEmpty = checkEmpty(value)
|
||||
|
||||
// If the field is empty and required, set the validation message,
|
||||
// otherwise skip the validation
|
||||
if (isEmpty) {
|
||||
validationResults[key] = field.required ? t('fieldRequired') : undefined
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate color format
|
||||
// Should be a valid hex color code
|
||||
if (field.type === 'color' && !/^#[0-9A-F]{6}$/i.test(value as string)) {
|
||||
validationResults[key] = t('invalidColor')
|
||||
continue
|
||||
}
|
||||
|
||||
if (field.type === 'icon') {
|
||||
if (!validateIconName(stringToIcon(value as string))) {
|
||||
validationResults[key] = t('invalidIcon')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await loadIcon(value as string)
|
||||
} catch {
|
||||
validationResults[key] = t('invalidIcon')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const validator = (
|
||||
field as FormFieldPropsUnion & {
|
||||
validator?:
|
||||
| ((value: any, formState: FormState) => boolean | string)
|
||||
| ZodType
|
||||
}
|
||||
).validator
|
||||
|
||||
const result = validator
|
||||
? validateField(validator, value as never, data)
|
||||
: true
|
||||
|
||||
if (typeof result === 'string') {
|
||||
validationResults[key] = result
|
||||
continue
|
||||
}
|
||||
|
||||
validationResults[key] = result === true ? undefined : 'Invalid field'
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.values(validationResults).every(result => result === undefined)
|
||||
) {
|
||||
setErrorMsgs(validationResults)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Call the onSubmit callback with the final data
|
||||
// Close the modal on successful submission
|
||||
if (onSubmit) {
|
||||
try {
|
||||
await onSubmit(
|
||||
finalData as InferFormFinalState<typeof fieldTypes, typeof fields>
|
||||
)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
// Leave the error handling to the parent component
|
||||
console.log('Form submission error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeErrorMsg = (fieldId: string) => {
|
||||
setErrorMsgs(prev => ({ ...prev, [fieldId]: undefined }))
|
||||
}
|
||||
|
||||
const [submitButtonLoading, onSubmitButtonClick] =
|
||||
usePromiseLoading(handleSubmit)
|
||||
|
||||
// Notify parent component of data changes
|
||||
useEffect(() => {
|
||||
onChange?.(data)
|
||||
|
||||
if (_.isEqual(data, dataStore.getState())) {
|
||||
// If the data is shallow equal to the previous data,
|
||||
// we can skip the update
|
||||
return
|
||||
}
|
||||
|
||||
dataStore.setState(data)
|
||||
}, [data, onChange])
|
||||
|
||||
return (
|
||||
<Flex direction="column" minWidth="50vw">
|
||||
<ModalHeader
|
||||
actionButtonProps={
|
||||
actionButton && {
|
||||
...actionButton,
|
||||
onClick: () => actionButton?.onClick?.(data, setData)
|
||||
}
|
||||
}
|
||||
icon={icon}
|
||||
namespace={namespace ? namespace : undefined}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!loading ? (
|
||||
<>
|
||||
<FormInputs
|
||||
autoFocusableFieldId={autoFocusableFieldId}
|
||||
conditionalFields={conditionalFields}
|
||||
data={data as FormState}
|
||||
errorMsgs={errorMsgs}
|
||||
fields={fields}
|
||||
namespace={namespace}
|
||||
removeErrorMsg={removeErrorMsg}
|
||||
setData={setData}
|
||||
/>
|
||||
<SubmitButton
|
||||
submitButton={submitButton}
|
||||
submitLoading={submitButtonLoading}
|
||||
onSubmitButtonClick={onSubmitButtonClick}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Flex
|
||||
align="center"
|
||||
direction="column"
|
||||
flex="1"
|
||||
justify="center"
|
||||
minHeight="24em"
|
||||
>
|
||||
<LoadingScreen />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type React from 'react'
|
||||
import type { ZodType } from 'zod'
|
||||
|
||||
import type { CheckboxFieldProps } from '../components/FormInputs/components/FormCheckboxInput'
|
||||
import type { ColorFieldProps } from '../components/FormInputs/components/FormColorInput'
|
||||
import type { CurrencyFieldProps } from '../components/FormInputs/components/FormCurrencyInput'
|
||||
import type { DateFieldProps } from '../components/FormInputs/components/FormDateInput'
|
||||
import type { FileFieldProps } from '../components/FormInputs/components/FormFileInput'
|
||||
import type { IconFieldProps } from '../components/FormInputs/components/FormIconInput'
|
||||
import type { ListboxFieldProps } from '../components/FormInputs/components/FormListboxInput'
|
||||
import type { LocationFieldProps } from '../components/FormInputs/components/FormLocationInput'
|
||||
import type { NumberFieldProps } from '../components/FormInputs/components/FormNumberInput'
|
||||
import type { RRuleFieldProps } from '../components/FormInputs/components/FormRRuleInput'
|
||||
import type { SliderFieldProps } from '../components/FormInputs/components/FormSliderInput'
|
||||
import type { TextAreaFieldProps } from '../components/FormInputs/components/FormTextAreaInput'
|
||||
import type { TextFieldProps } from '../components/FormInputs/components/FormTextInput'
|
||||
|
||||
export type BaseFieldProps<
|
||||
TFormDataType,
|
||||
TFinalDataType,
|
||||
TAutoFocusable extends boolean = false
|
||||
> = {
|
||||
label: string
|
||||
hidden?: boolean
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
disabledReason?: string | React.ReactElement
|
||||
__formDataType: TFormDataType
|
||||
__finalDataType: TFinalDataType
|
||||
__autoFocusable?: TAutoFocusable
|
||||
}
|
||||
|
||||
/** --------- Utility Types ----------- */
|
||||
export type UnionToIntersection<U> = (
|
||||
U extends any ? (k: U) => void : never
|
||||
) extends (k: infer I) => void
|
||||
? I
|
||||
: never
|
||||
|
||||
export type DistributiveOmit<T, K extends keyof any> = T extends any
|
||||
? Omit<T, K>
|
||||
: never
|
||||
|
||||
export type FieldsMap<T> = UnionToIntersection<
|
||||
T extends { type: infer K }
|
||||
? K extends string | number | symbol
|
||||
? { [P in K]: Extract<T, { type: K }> }
|
||||
: never
|
||||
: never
|
||||
>
|
||||
|
||||
export type FormFieldTypeMap = FieldsMap<FormFieldPropsUnion>
|
||||
|
||||
export type FormState = Record<string, any>
|
||||
|
||||
// Find field type by data type (usually for automatic inference)
|
||||
export type MatchFieldByFormDataType<T> = Extract<
|
||||
FormFieldPropsUnion,
|
||||
{ __finalDataType: T extends { __type: 'media' } ? string | File : T }
|
||||
>
|
||||
|
||||
export type MatchFieldByType<TType> = Extract<
|
||||
FormFieldPropsUnion,
|
||||
{ type: TType }
|
||||
>
|
||||
|
||||
// Find props by field type (for customization/override)
|
||||
export type MatchFieldByFieldType<TField> = TField extends {
|
||||
type: infer FieldType
|
||||
}
|
||||
? MatchFieldByType<FieldType>
|
||||
: never
|
||||
|
||||
// Single field inference
|
||||
export type Field<TField extends FormFieldPropsUnion> = TField &
|
||||
BaseFieldProps<TField['__formDataType'], TField['__finalDataType']>
|
||||
|
||||
// Props for component usage
|
||||
export type FormInputProps<TField extends FormFieldPropsUnion> = {
|
||||
field: Field<TField> & {
|
||||
errorMsg?: string
|
||||
}
|
||||
value: TField['__formDataType']
|
||||
autoFocus?: boolean
|
||||
namespace?: string
|
||||
options?: {
|
||||
value: string
|
||||
text: string
|
||||
icon?: string
|
||||
color?: string
|
||||
}[]
|
||||
handleChange: (value: TField['__formDataType']) => void
|
||||
}
|
||||
|
||||
/** --------- Form Field Configuration (for developers) ----------- */
|
||||
|
||||
export type InferFinalFieldConfig<
|
||||
TFieldType extends FormFieldPropsUnion['type'],
|
||||
TFormState,
|
||||
TAllFormState
|
||||
> = TFieldType extends 'listbox'
|
||||
? ListboxFieldProps<
|
||||
TFormState extends Array<infer TOption> ? TOption : TFormState,
|
||||
boolean,
|
||||
TAllFormState
|
||||
>
|
||||
: TFieldType extends 'file'
|
||||
? FileFieldProps<
|
||||
TFormState extends { config: { optional: infer TOptional } }
|
||||
? TOptional extends boolean
|
||||
? TOptional
|
||||
: false
|
||||
: false
|
||||
>
|
||||
: FormFieldTypeMap[TFieldType]
|
||||
|
||||
export type FieldsConfig<
|
||||
TFormState extends FormState = FormState,
|
||||
TFieldType extends {
|
||||
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
|
||||
} = {
|
||||
[K in keyof TFormState]: MatchFieldByFormDataType<TFormState[K]>['type']
|
||||
}
|
||||
> = Partial<{
|
||||
[K in keyof TFormState]: InferFinalFieldConfig<
|
||||
TFieldType[K],
|
||||
TFormState[K],
|
||||
InferFormState<TFieldType, TFormState>
|
||||
> extends infer TField
|
||||
? DistributiveOmit<
|
||||
TField,
|
||||
'type' | '__finalDataType' | '__formDataType' | '__autoFocusable'
|
||||
> & {
|
||||
validator?:
|
||||
| ((
|
||||
value: InferFormFinalState<TFieldType, TFormState>[K],
|
||||
formState: TFormState
|
||||
) => boolean | string)
|
||||
| ZodType
|
||||
}
|
||||
: never
|
||||
}>
|
||||
|
||||
export type InferListboxOptions<TOption> = TOption extends {
|
||||
value: infer TValue
|
||||
text: string
|
||||
}
|
||||
? TValue
|
||||
: never
|
||||
|
||||
export type ExtractOptionsArray<TOptions> =
|
||||
TOptions extends Array<infer TOption>
|
||||
? TOption
|
||||
: TOptions extends (...args: any[]) => Array<infer TOption>
|
||||
? TOption
|
||||
: never
|
||||
|
||||
export type InferListBoxFormState<TField> = TField extends {
|
||||
options: infer TOptions
|
||||
}
|
||||
? TField extends {
|
||||
multiple: true
|
||||
}
|
||||
? InferListboxOptions<ExtractOptionsArray<TOptions>>[]
|
||||
: InferListboxOptions<ExtractOptionsArray<TOptions>>
|
||||
: never
|
||||
|
||||
export type InferFormState<TFieldTypes, TFields> = {
|
||||
[K in keyof TFieldTypes]: TFieldTypes[K] extends infer TFieldType
|
||||
? TFieldType extends 'listbox'
|
||||
? InferListBoxFormState<TFields[K extends keyof TFields ? K : never]>
|
||||
: MatchFieldByType<TFieldType>['__formDataType']
|
||||
: never
|
||||
}
|
||||
|
||||
export type InferListBoxFinalState<TField> = TField extends {
|
||||
options: infer TOptions
|
||||
}
|
||||
? TField extends { multiple: true }
|
||||
? InferListboxOptions<ExtractOptionsArray<TOptions>>[]
|
||||
: TField extends { required: true }
|
||||
? InferListboxOptions<ExtractOptionsArray<TOptions>>
|
||||
: InferListboxOptions<ExtractOptionsArray<TOptions>> | undefined
|
||||
: never
|
||||
|
||||
export type InferFormFinalState<
|
||||
TFieldTypes,
|
||||
TFieldProps extends FieldsConfig<any, any>
|
||||
> = {
|
||||
[K in keyof TFieldTypes]: TFieldTypes[K] extends infer TFieldType
|
||||
? TFieldType extends 'listbox'
|
||||
? InferListBoxFinalState<TFieldProps[K]>
|
||||
: MatchFieldByType<TFieldType>['__finalDataType']
|
||||
: never
|
||||
}
|
||||
|
||||
export type IsAutoFocusable<TType> =
|
||||
MatchFieldByType<TType> extends infer TField
|
||||
? TField extends { __autoFocusable?: true }
|
||||
? true
|
||||
: false
|
||||
: false
|
||||
|
||||
export type InferAutoFocusableFieldIds<TFieldTypes> = {
|
||||
[K in keyof TFieldTypes]: IsAutoFocusable<TFieldTypes[K]> extends true
|
||||
? K
|
||||
: never
|
||||
}[keyof TFieldTypes]
|
||||
|
||||
export type FormFieldPropsUnion =
|
||||
| TextFieldProps
|
||||
| NumberFieldProps
|
||||
| CurrencyFieldProps
|
||||
| TextAreaFieldProps
|
||||
| DateFieldProps
|
||||
| ListboxFieldProps
|
||||
| ColorFieldProps
|
||||
| IconFieldProps
|
||||
| FileFieldProps
|
||||
| LocationFieldProps
|
||||
| CheckboxFieldProps
|
||||
| RRuleFieldProps
|
||||
| SliderFieldProps
|
||||
@@ -1,226 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/**
|
||||
* Transforms existing data based on field type to ensure proper format for form inputs.
|
||||
*
|
||||
* This function handles type-specific transformations when loading existing data into forms.
|
||||
* Currently handles datetime fields by converting string dates to Date objects.
|
||||
*
|
||||
* @param fieldType - The type of the form field (e.g., 'text', 'number', 'datetime')
|
||||
* @param value - The raw value to transform
|
||||
* @returns The transformed value ready for form input, or the original value if no transformation needed
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Transform datetime string to Date object
|
||||
* const dateValue = transformExistedData('datetime', '2023-12-25T10:30:00Z')
|
||||
* // Returns: Date object
|
||||
*
|
||||
* // Non-datetime fields pass through unchanged
|
||||
* const textValue = transformExistedData('text', 'Hello World')
|
||||
* // Returns: 'Hello World'
|
||||
* ```
|
||||
*/
|
||||
export const transformExistedData = (
|
||||
fieldType: string,
|
||||
value: unknown
|
||||
): unknown => {
|
||||
if (fieldType === 'datetime' && value) {
|
||||
return dayjs(value as string).toDate()
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a form field value should be considered empty.
|
||||
*
|
||||
* This utility function provides comprehensive empty value detection across different data types
|
||||
* commonly used in forms. It handles various edge cases including null objects, empty arrays,
|
||||
* invalid dates, and custom object structures used by specific form components.
|
||||
*
|
||||
* @param value - The value to check for emptiness
|
||||
* @returns `true` if the value is considered empty, `false` otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic empty checks
|
||||
* checkEmpty(null) // true
|
||||
* checkEmpty(undefined) // true
|
||||
* checkEmpty('') // true
|
||||
* checkEmpty(' ') // true
|
||||
* checkEmpty([]) // true
|
||||
* checkEmpty({}) // true
|
||||
*
|
||||
* // Date checks
|
||||
* checkEmpty(new Date('invalid')) // true
|
||||
* checkEmpty(new Date()) // false
|
||||
*
|
||||
* // File field checks
|
||||
* checkEmpty({ file: null, preview: null }) // true
|
||||
* checkEmpty({ file: 'removed', preview: '' }) // true
|
||||
* checkEmpty({ file: fileObject, preview: 'data:image...' }) // false
|
||||
*
|
||||
* // Location field checks
|
||||
* checkEmpty({ name: '', formattedAddress: '' }) // true
|
||||
* checkEmpty({ name: 'Home', formattedAddress: '123 St' }) // false
|
||||
*
|
||||
* // Valid values
|
||||
* checkEmpty('hello') // false
|
||||
* checkEmpty(0) // false
|
||||
* checkEmpty(false) // false
|
||||
* checkEmpty(['item']) // false
|
||||
* ```
|
||||
*/
|
||||
export const checkEmpty = (value: unknown): boolean => {
|
||||
if (value === null || value === undefined) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return isNaN(value.getTime())
|
||||
}
|
||||
|
||||
if (value instanceof File) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (Object.keys(value).length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ('file' in value && (!value.file || value.file === 'removed')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
'name' in value &&
|
||||
'formattedAddress' in value &&
|
||||
!value.name &&
|
||||
!value.formattedAddress
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates initial form data with appropriate default values based on field types.
|
||||
*
|
||||
* This function creates a complete initial state object for form fields, handling both
|
||||
* existing data transformation and default value assignment. It ensures that each field
|
||||
* has an appropriate initial value based on its type, preventing undefined states.
|
||||
*
|
||||
* @template TFormConfig - The form configuration type extending FieldsConfig
|
||||
* @param fieldTypes - Object mapping field names to their input types
|
||||
* @param fields - The complete field configuration object
|
||||
* @param formExistedData - Optional existing data to populate fields with
|
||||
* @returns Object with initial values for all form fields
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Define field types and configuration
|
||||
* const fieldTypes = {
|
||||
* name: 'text',
|
||||
* age: 'number',
|
||||
* birthDate: 'datetime',
|
||||
* tags: 'listbox',
|
||||
* isActive: 'checkbox',
|
||||
* avatar: 'file'
|
||||
* }
|
||||
*
|
||||
* const fields = {
|
||||
* name: { label: 'Name', required: true },
|
||||
* age: { label: 'Age', min: 0 },
|
||||
* birthDate: { label: 'Birth Date' },
|
||||
* tags: { label: 'Tags', multiple: true },
|
||||
* isActive: { label: 'Active' },
|
||||
* avatar: { label: 'Profile Picture' }
|
||||
* }
|
||||
*
|
||||
* // Generate initial data without existing data
|
||||
* const initialData = getInitialData(fieldTypes, fields)
|
||||
* // Returns:
|
||||
* // {
|
||||
* // name: '',
|
||||
* // age: 0,
|
||||
* // birthDate: null,
|
||||
* // tags: [], // multiple listbox
|
||||
* // isActive: false,
|
||||
* // avatar: { file: null, preview: null }
|
||||
* // }
|
||||
*
|
||||
* // Generate initial data with existing data
|
||||
* const existingData = {
|
||||
* name: 'John Doe',
|
||||
* age: 30,
|
||||
* birthDate: '1993-01-15T00:00:00Z'
|
||||
* }
|
||||
*
|
||||
* const initialDataWithExisting = getInitialData(fieldTypes, fields, existingData)
|
||||
* // Returns:
|
||||
* // {
|
||||
* // name: 'John Doe',
|
||||
* // age: 30,
|
||||
* // birthDate: Date object (transformed from string),
|
||||
* // tags: [], // uses default for missing fields
|
||||
* // isActive: false,
|
||||
* // avatar: { file: null, preview: null }
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export const getInitialData = (
|
||||
fieldTypes: Record<string, any>,
|
||||
fields: Record<string, any>,
|
||||
initialData?: Record<string, any>
|
||||
): Record<string, any> => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(fieldTypes).map(([key, fieldType]) => {
|
||||
// Use existing data if available and not empty
|
||||
if (initialData && key in initialData && initialData[key]) {
|
||||
return [key, transformExistedData(fieldType, initialData[key])]
|
||||
}
|
||||
|
||||
// Set appropriate default value based on field type
|
||||
let finalValue: unknown = ''
|
||||
|
||||
switch (fieldType) {
|
||||
case 'number':
|
||||
case 'currency':
|
||||
finalValue = 0
|
||||
break
|
||||
case 'datetime':
|
||||
case 'location':
|
||||
finalValue = null
|
||||
break
|
||||
case 'listbox':
|
||||
// Handle single vs multiple selection listboxes
|
||||
finalValue = fields[key]?.multiple ? [] : null
|
||||
break
|
||||
case 'checkbox':
|
||||
finalValue = false
|
||||
break
|
||||
case 'file':
|
||||
// File fields need object structure for file and preview
|
||||
finalValue = { file: null, preview: null }
|
||||
break
|
||||
default:
|
||||
// Default to empty string for text-based inputs
|
||||
finalValue = ''
|
||||
}
|
||||
|
||||
return [key, finalValue]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
export * from './features/FormModal'
|
||||
export * from './ModalHeader'
|
||||
|
||||
export * from './features/ConfirmationModal'
|
||||
export * from './ModalWrapper'
|
||||
|
||||
export * from './features/ViewImageModal'
|
||||
export * from './ModalManager'
|
||||
|
||||
export * from './core/components/ModalHeader'
|
||||
export * from './ConfirmationModal'
|
||||
|
||||
export * from './core/components/ModalWrapper'
|
||||
|
||||
export * from './core/ModalManager'
|
||||
|
||||
export * from './features/FormModal/formBuilder'
|
||||
export * from './ViewImageModal'
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
if (Math.random() < 0) {
|
||||
// @ts-ignore
|
||||
import('./styles/index.css')
|
||||
}
|
||||
export * from './providers'
|
||||
|
||||
export * from './components/primitives'
|
||||
|
||||
export * from './components/auth'
|
||||
|
||||
export * from './components/data-display'
|
||||
|
||||
export * from './components/inputs'
|
||||
|
||||
export * from './components/feedback'
|
||||
|
||||
export * from './components/inputs'
|
||||
|
||||
export * from './components/layout'
|
||||
|
||||
export * from './components/navigation'
|
||||
|
||||
export * from './components/overlays'
|
||||
|
||||
export * from './components/utilities'
|
||||
export * from './components'
|
||||
|
||||
export {
|
||||
TAILWIND_PALETTE,
|
||||
@@ -37,5 +15,3 @@ export type {
|
||||
OpacityValue,
|
||||
ColorValue as ColorPropValue
|
||||
} from './system'
|
||||
|
||||
export * from './providers'
|
||||
|
||||
Reference in New Issue
Block a user