feat/TanStack-Form (#18346)

This commit is contained in:
Wu Tianwei
2025-04-18 15:54:22 +08:00
committed by GitHub
parent efe5db38ee
commit 1e7418095f
38 changed files with 959 additions and 127 deletions

View File

@@ -0,0 +1,43 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import Checkbox from '../../../checkbox'
type CheckboxFieldProps = {
label: string;
labelClassName?: string;
}
const CheckboxField = ({
label,
labelClassName,
}: CheckboxFieldProps) => {
const field = useFieldContext<boolean>()
return (
<div className='flex gap-2'>
<div className='flex h-6 shrink-0 items-center'>
<Checkbox
id={field.name}
checked={field.state.value}
onCheck={() => {
field.handleChange(!field.state.value)
}}
/>
</div>
<label
htmlFor={field.name}
className={cn(
'system-sm-medium grow cursor-pointer pt-1 text-text-secondary',
labelClassName,
)}
onClick={() => {
field.handleChange(!field.state.value)
}}
>
{label}
</label>
</div>
)
}
export default CheckboxField

View File

@@ -0,0 +1,49 @@
import React from 'react'
import { useFieldContext } from '../..'
import Label from '../label'
import cn from '@/utils/classnames'
import type { InputNumberProps } from '../../../input-number'
import { InputNumber } from '../../../input-number'
type TextFieldProps = {
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>
const NumberInputField = ({
label,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<number | undefined>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
/>
<InputNumber
id={field.name}
value={field.state.value}
onChange={value => field.handleChange(value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)
}
export default NumberInputField

View File

@@ -0,0 +1,34 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import Label from '../label'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
type OptionsFieldProps = {
label: string;
className?: string;
labelClassName?: string;
}
const OptionsField = ({
label,
className,
labelClassName,
}: OptionsFieldProps) => {
const field = useFieldContext<string[]>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
className={labelClassName}
/>
<ConfigSelect
options={field.state.value}
onChange={value => field.handleChange(value)}
/>
</div>
)
}
export default OptionsField

View File

@@ -0,0 +1,51 @@
import cn from '@/utils/classnames'
import { useFieldContext } from '../..'
import PureSelect from '../../../select/pure'
import Label from '../label'
type SelectOption = {
value: string
label: string
}
type SelectFieldProps = {
label: string
options: SelectOption[]
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
}
const SelectField = ({
label,
options,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
}: SelectFieldProps) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
/>
<PureSelect
value={field.state.value}
options={options}
onChange={value => field.handleChange(value)}
/>
</div>
)
}
export default SelectField

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { useFieldContext } from '../..'
import Input, { type InputProps } from '../../../input'
import Label from '../label'
import cn from '@/utils/classnames'
type TextFieldProps = {
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
labelClassName?: string
} & Omit<InputProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
const TextField = ({
label,
isRequired,
showOptional,
tooltip,
className,
labelClassName,
...inputProps
}: TextFieldProps) => {
const field = useFieldContext<string>()
return (
<div className={cn('flex flex-col gap-y-0.5', className)}>
<Label
htmlFor={field.name}
label={label}
isRequired={isRequired}
showOptional={showOptional}
tooltip={tooltip}
className={labelClassName}
/>
<Input
id={field.name}
value={field.state.value}
onChange={e => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
{...inputProps}
/>
</div>
)
}
export default TextField

View File

@@ -0,0 +1,25 @@
import { useStore } from '@tanstack/react-form'
import { useFormContext } from '../..'
import Button, { type ButtonProps } from '../../../button'
type SubmitButtonProps = Omit<ButtonProps, 'disabled' | 'loading' | 'onClick'>
const SubmitButton = ({ ...buttonProps }: SubmitButtonProps) => {
const form = useFormContext()
const [isSubmitting, canSubmit] = useStore(form.store, state => [
state.isSubmitting,
state.canSubmit,
])
return (
<Button
disabled={isSubmitting || !canSubmit}
loading={isSubmitting}
onClick={() => form.handleSubmit()}
{...buttonProps}
/>
)
}
export default SubmitButton

View File

@@ -0,0 +1,53 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Label from './label'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('Label Component', () => {
const defaultProps = {
htmlFor: 'test-input',
label: 'Test Label',
}
it('renders basic label correctly', () => {
render(<Label {...defaultProps} />)
const label = screen.getByTestId('label')
expect(label).toBeInTheDocument()
expect(label).toHaveAttribute('for', 'test-input')
})
it('shows optional text when showOptional is true', () => {
render(<Label {...defaultProps} showOptional />)
expect(screen.getByText('common.label.optional')).toBeInTheDocument()
})
it('shows required asterisk when isRequired is true', () => {
render(<Label {...defaultProps} isRequired />)
expect(screen.getByText('*')).toBeInTheDocument()
})
it('renders tooltip when tooltip prop is provided', () => {
const tooltipText = 'Test Tooltip'
render(<Label {...defaultProps} tooltip={tooltipText} />)
const trigger = screen.getByTestId('test-input-tooltip')
fireEvent.mouseEnter(trigger)
expect(screen.getByText(tooltipText)).toBeInTheDocument()
})
it('applies custom className when provided', () => {
const customClass = 'custom-label'
render(<Label {...defaultProps} className={customClass} />)
const label = screen.getByTestId('label')
expect(label).toHaveClass(customClass)
})
it('does not show optional text and required asterisk simultaneously', () => {
render(<Label {...defaultProps} isRequired showOptional />)
expect(screen.queryByText('common.label.optional')).not.toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,48 @@
import cn from '@/utils/classnames'
import Tooltip from '../../tooltip'
import { useTranslation } from 'react-i18next'
export type LabelProps = {
htmlFor: string
label: string
isRequired?: boolean
showOptional?: boolean
tooltip?: string
className?: string
}
const Label = ({
htmlFor,
label,
isRequired,
showOptional,
tooltip,
className,
}: LabelProps) => {
const { t } = useTranslation()
return (
<div className='flex h-6 items-center'>
<label
data-testid='label'
htmlFor={htmlFor}
className={cn('system-sm-medium text-text-secondary', className)}
>
{label}
</label>
{!isRequired && showOptional && <div className='system-xs-regular ml-1 text-text-tertiary'>{t('common.label.optional')}</div>}
{isRequired && <div className='system-xs-regular ml-1 text-text-destructive-secondary'>*</div>}
{tooltip && (
<Tooltip
popupContent={
<div className='w-[200px]'>{tooltip}</div>
}
triggerClassName='ml-0.5 w-4 h-4'
triggerTestId={`${htmlFor}-tooltip`}
/>
)}
</div>
)
}
export default Label

View File

@@ -0,0 +1,35 @@
import { withForm } from '../..'
import { demoFormOpts } from './shared-options'
import { ContactMethods } from './types'
const ContactFields = withForm({
...demoFormOpts,
render: ({ form }) => {
return (
<div className='my-2'>
<h3 className='title-lg-bold text-text-primary'>Contacts</h3>
<div className='flex flex-col gap-4'>
<form.AppField
name='contact.email'
children={field => <field.TextField label='Email' />}
/>
<form.AppField
name='contact.phone'
children={field => <field.TextField label='Phone' />}
/>
<form.AppField
name='contact.preferredContactMethod'
children={field => (
<field.SelectField
label='Preferred Contact Method'
options={ContactMethods}
/>
)}
/>
</div>
</div>
)
},
})
export default ContactFields

View File

@@ -0,0 +1,68 @@
import { useStore } from '@tanstack/react-form'
import { useAppForm } from '../..'
import ContactFields from './contact-fields'
import { demoFormOpts } from './shared-options'
import { UserSchema } from './types'
const DemoForm = () => {
const form = useAppForm({
...demoFormOpts,
validators: {
onSubmit: ({ value }) => {
// Validate the entire form
const result = UserSchema.safeParse(value)
if (!result.success) {
const issues = result.error.issues
console.log('Validation errors:', issues)
return issues[0].message
}
return undefined
},
},
onSubmit: ({ value }) => {
console.log('Form submitted:', value)
},
})
const name = useStore(form.store, state => state.values.name)
return (
<form
className='flex w-[400px] flex-col gap-4'
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.AppField
name='name'
children={field => (
<field.TextField label='Name' />
)}
/>
<form.AppField
name='surname'
children={field => (
<field.TextField label='Surname' />
)}
/>
<form.AppField
name='isAcceptingTerms'
children={field => (
<field.CheckboxField label='I accept the terms and conditions.' />
)}
/>
{
!!name && (
<ContactFields form={form} />
)
}
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</form>
)
}
export default DemoForm

View File

@@ -0,0 +1,14 @@
import { formOptions } from '@tanstack/react-form'
export const demoFormOpts = formOptions({
defaultValues: {
name: '',
surname: '',
isAcceptingTerms: false,
contact: {
email: '',
phone: '',
preferredContactMethod: 'email',
},
},
})

View File

@@ -0,0 +1,34 @@
import { z } from 'zod'
const ContactMethod = z.union([
z.literal('email'),
z.literal('phone'),
z.literal('whatsapp'),
z.literal('sms'),
])
export const ContactMethods = ContactMethod.options.map(({ value }) => ({
value,
label: value.charAt(0).toUpperCase() + value.slice(1),
}))
export const UserSchema = z.object({
name: z
.string()
.regex(/^[A-Z]/, 'Name must start with a capital letter')
.min(3, 'Name must be at least 3 characters long'),
surname: z
.string()
.min(3, 'Surname must be at least 3 characters long')
.regex(/^[A-Z]/, 'Surname must start with a capital letter'),
isAcceptingTerms: z.boolean().refine(val => val, {
message: 'You must accept the terms and conditions',
}),
contact: z.object({
email: z.string().email('Invalid email address'),
phone: z.string().optional(),
preferredContactMethod: ContactMethod,
}),
})
export type User = z.infer<typeof UserSchema>

View File

@@ -0,0 +1,25 @@
import { createFormHook, createFormHookContexts } from '@tanstack/react-form'
import TextField from './components/field/text'
import NumberInputField from './components/field/number-input'
import CheckboxField from './components/field/checkbox'
import SelectField from './components/field/select'
import OptionsField from './components/field/options'
import SubmitButton from './components/form/submit-button'
export const { fieldContext, useFieldContext, formContext, useFormContext }
= createFormHookContexts()
export const { useAppForm, withForm } = createFormHook({
fieldComponents: {
TextField,
NumberInputField,
CheckboxField,
SelectField,
OptionsField,
},
formComponents: {
SubmitButton,
},
fieldContext,
formContext,
})