mirror of
https://github.com/langgenius/dify.git
synced 2026-02-26 12:37:18 +08:00
test(base): add test coverage for more base/form components (#32437)
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,48 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import AuthForm from './index'
|
||||
|
||||
const formSchemas = [{
|
||||
type: FormTypeEnum.textInput,
|
||||
name: 'apiKey',
|
||||
label: 'API Key',
|
||||
required: true,
|
||||
}] as const
|
||||
|
||||
const renderWithQueryClient = (ui: Parameters<typeof render>[0]) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuthForm', () => {
|
||||
it('should render configured fields', () => {
|
||||
renderWithQueryClient(<AuthForm formSchemas={[...formSchemas]} />)
|
||||
|
||||
expect(screen.getByText('API Key')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use provided default values', () => {
|
||||
renderWithQueryClient(<AuthForm formSchemas={[...formSchemas]} defaultValues={{ apiKey: 'value-123' }} />)
|
||||
|
||||
expect(screen.getByDisplayValue('value-123')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render nothing when no schema is provided', () => {
|
||||
const { container } = renderWithQueryClient(<AuthForm formSchemas={[]} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
137
web/app/components/base/form/form-scenarios/base/field.spec.tsx
Normal file
137
web/app/components/base/form/form-scenarios/base/field.spec.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import type { BaseConfiguration } from './types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useMemo } from 'react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useAppForm } from '../..'
|
||||
import BaseField from './field'
|
||||
import { BaseFieldType } from './types'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
const createConfig = (overrides: Partial<BaseConfiguration> = {}): BaseConfiguration => ({
|
||||
type: BaseFieldType.textInput,
|
||||
variable: 'fieldA',
|
||||
label: 'Field A',
|
||||
required: false,
|
||||
showConditions: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
type FieldHarnessProps = {
|
||||
config: BaseConfiguration
|
||||
initialData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => {
|
||||
const form = useAppForm({
|
||||
defaultValues: initialData,
|
||||
onSubmit: () => {},
|
||||
})
|
||||
const Component = useMemo(() => BaseField({ initialData, config }), [config, initialData])
|
||||
|
||||
return <Component form={form} />
|
||||
}
|
||||
|
||||
describe('BaseField', () => {
|
||||
it('should render a text input field when configured as text input', () => {
|
||||
render(<FieldHarness config={createConfig({ label: 'Username' })} initialData={{ fieldA: '' }} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('Username')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a number input when configured as number input', () => {
|
||||
render(<FieldHarness config={createConfig({ type: BaseFieldType.numberInput, label: 'Age' })} initialData={{ fieldA: 20 }} />)
|
||||
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument()
|
||||
expect(screen.getByText('Age')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a checkbox when configured as checkbox', () => {
|
||||
render(<FieldHarness config={createConfig({ type: BaseFieldType.checkbox, label: 'Agree' })} initialData={{ fieldA: false }} />)
|
||||
|
||||
expect(screen.getByText('Agree')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render paragraph and select fields based on configuration', () => {
|
||||
const scenarios: Array<{ config: BaseConfiguration, initialData: Record<string, unknown> }> = [
|
||||
{
|
||||
config: createConfig({
|
||||
type: BaseFieldType.paragraph,
|
||||
label: 'Description',
|
||||
}),
|
||||
initialData: { fieldA: 'hello' },
|
||||
},
|
||||
{
|
||||
config: createConfig({
|
||||
type: BaseFieldType.select,
|
||||
label: 'Mode',
|
||||
options: [{ value: 'safe', label: 'Safe' }],
|
||||
}),
|
||||
initialData: { fieldA: 'safe' },
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />)
|
||||
expect(screen.getByText(scenario.config.label)).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('should render file uploader when configured as file', () => {
|
||||
const scenarios: Array<{ config: BaseConfiguration, initialData: Record<string, unknown> }> = [
|
||||
{
|
||||
config: createConfig({
|
||||
type: BaseFieldType.file,
|
||||
label: 'Attachment',
|
||||
allowedFileExtensions: ['txt'],
|
||||
allowedFileTypes: ['document'],
|
||||
allowedFileUploadMethods: [TransferMethod.local_file],
|
||||
}),
|
||||
initialData: { fieldA: [] },
|
||||
},
|
||||
{
|
||||
config: createConfig({
|
||||
type: BaseFieldType.fileList,
|
||||
label: 'Attachments',
|
||||
maxLength: 2,
|
||||
allowedFileExtensions: ['txt'],
|
||||
allowedFileTypes: ['document'],
|
||||
allowedFileUploadMethods: [TransferMethod.local_file],
|
||||
}),
|
||||
initialData: { fieldA: [] },
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />)
|
||||
expect(screen.getByText(scenario.config.label)).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({ type: 'unsupported' as BaseFieldType, label: 'Unsupported' })}
|
||||
initialData={{ fieldA: '' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('Unsupported')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when show conditions are not met', () => {
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({
|
||||
label: 'Hidden Field',
|
||||
showConditions: [{ variable: 'toggle', value: true }],
|
||||
})}
|
||||
initialData={{ fieldA: '', toggle: false }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Hidden Field')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,94 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import BaseForm from './index'
|
||||
import { BaseFieldType } from './types'
|
||||
|
||||
const baseConfigurations = [{
|
||||
type: BaseFieldType.textInput,
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
required: false,
|
||||
showConditions: [],
|
||||
}]
|
||||
|
||||
describe('BaseForm', () => {
|
||||
it('should render configured fields', () => {
|
||||
render(
|
||||
<BaseForm
|
||||
initialData={{ name: 'Alice' }}
|
||||
configurations={[...baseConfigurations]}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should submit current form values when submit button is clicked', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<BaseForm
|
||||
initialData={{ name: 'Alice' }}
|
||||
configurations={[...baseConfigurations]}
|
||||
onSubmit={onSubmit}
|
||||
CustomActions={({ form }) => (
|
||||
<button type="button" onClick={() => form.handleSubmit()}>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Alice' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should render custom actions when provided', () => {
|
||||
render(
|
||||
<BaseForm
|
||||
initialData={{ name: 'Alice' }}
|
||||
configurations={[...baseConfigurations]}
|
||||
onSubmit={() => {}}
|
||||
CustomActions={() => <button type="button">Save Form</button>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /save form/i })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /common.operation.submit/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle native form submit and block invalid submission', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const requiredConfig = [{
|
||||
type: BaseFieldType.textInput,
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
maxLength: 2,
|
||||
}]
|
||||
const { container } = render(
|
||||
<BaseForm
|
||||
initialData={{ name: 'ok' }}
|
||||
configurations={requiredConfig}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
)
|
||||
|
||||
const form = container.querySelector('form')
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(form).not.toBeNull()
|
||||
|
||||
fireEvent.submit(form!)
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'ok' })
|
||||
})
|
||||
|
||||
fireEvent.change(input, { target: { value: 'long' } })
|
||||
fireEvent.submit(form!)
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,15 @@
|
||||
import { BaseFieldType } from './types'
|
||||
|
||||
describe('base scenario types', () => {
|
||||
it('should include all supported base field types', () => {
|
||||
expect(Object.values(BaseFieldType)).toEqual([
|
||||
'text-input',
|
||||
'paragraph',
|
||||
'number-input',
|
||||
'checkbox',
|
||||
'select',
|
||||
'file',
|
||||
'file-list',
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,49 @@
|
||||
import { BaseFieldType } from './types'
|
||||
import { generateZodSchema } from './utils'
|
||||
|
||||
describe('base scenario schema generator', () => {
|
||||
it('should validate required text fields with max length', () => {
|
||||
const schema = generateZodSchema([{
|
||||
type: BaseFieldType.textInput,
|
||||
variable: 'name',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
maxLength: 3,
|
||||
showConditions: [],
|
||||
}])
|
||||
|
||||
expect(schema.safeParse({ name: 'abc' }).success).toBe(true)
|
||||
expect(schema.safeParse({ name: '' }).success).toBe(false)
|
||||
expect(schema.safeParse({ name: 'abcd' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate number bounds', () => {
|
||||
const schema = generateZodSchema([{
|
||||
type: BaseFieldType.numberInput,
|
||||
variable: 'age',
|
||||
label: 'Age',
|
||||
required: true,
|
||||
min: 18,
|
||||
max: 30,
|
||||
showConditions: [],
|
||||
}])
|
||||
|
||||
expect(schema.safeParse({ age: 20 }).success).toBe(true)
|
||||
expect(schema.safeParse({ age: 17 }).success).toBe(false)
|
||||
expect(schema.safeParse({ age: 31 }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow optional fields to be undefined or null', () => {
|
||||
const schema = generateZodSchema([{
|
||||
type: BaseFieldType.select,
|
||||
variable: 'mode',
|
||||
label: 'Mode',
|
||||
required: false,
|
||||
showConditions: [],
|
||||
options: [{ value: 'safe', label: 'Safe' }],
|
||||
}])
|
||||
|
||||
expect(schema.safeParse({}).success).toBe(true)
|
||||
expect(schema.safeParse({ mode: null }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useAppForm } from '../..'
|
||||
import ContactFields from './contact-fields'
|
||||
import { demoFormOpts } from './shared-options'
|
||||
|
||||
const ContactFieldsHarness = () => {
|
||||
const form = useAppForm({
|
||||
...demoFormOpts,
|
||||
onSubmit: () => {},
|
||||
})
|
||||
|
||||
return <ContactFields form={form} />
|
||||
}
|
||||
|
||||
describe('ContactFields', () => {
|
||||
it('should render contact section fields', () => {
|
||||
render(<ContactFieldsHarness />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: /contacts/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: /phone/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/preferred contact method/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,69 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import DemoForm from './index'
|
||||
|
||||
describe('DemoForm', () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the primary fields', () => {
|
||||
render(<DemoForm />)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: /^name$/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: /^surname$/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/i accept the terms and conditions/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show contact fields after a name is entered', () => {
|
||||
render(<DemoForm />)
|
||||
|
||||
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /^name$/i }), { target: { value: 'Alice' } })
|
||||
|
||||
expect(screen.getByRole('heading', { name: /contacts/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide contact fields when name is cleared', () => {
|
||||
render(<DemoForm />)
|
||||
const nameInput = screen.getByRole('textbox', { name: /^name$/i })
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Alice' } })
|
||||
expect(screen.getByRole('heading', { name: /contacts/i })).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: '' } })
|
||||
expect(screen.queryByRole('heading', { name: /contacts/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should log validation errors on invalid submit', () => {
|
||||
render(<DemoForm />)
|
||||
const nameInput = screen.getByRole('textbox', { name: /^name$/i }) as HTMLInputElement
|
||||
|
||||
fireEvent.submit(nameInput.form!)
|
||||
|
||||
return waitFor(() => {
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('Validation errors:', expect.any(Array))
|
||||
})
|
||||
})
|
||||
|
||||
it('should log submitted values on valid submit', () => {
|
||||
render(<DemoForm />)
|
||||
const nameInput = screen.getByRole('textbox', { name: /^name$/i }) as HTMLInputElement
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Alice' } })
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /^surname$/i }), { target: { value: 'Smith' } })
|
||||
fireEvent.click(screen.getByText(/i accept the terms and conditions/i))
|
||||
fireEvent.change(screen.getByRole('textbox', { name: /email/i }), { target: { value: 'alice@example.com' } })
|
||||
fireEvent.submit(nameInput.form!)
|
||||
|
||||
return waitFor(() => {
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('Form submitted:', expect.objectContaining({
|
||||
name: 'Alice',
|
||||
surname: 'Smith',
|
||||
isAcceptingTerms: true,
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,16 @@
|
||||
import { demoFormOpts } from './shared-options'
|
||||
|
||||
describe('demoFormOpts', () => {
|
||||
it('should provide expected default values', () => {
|
||||
expect(demoFormOpts.defaultValues).toEqual({
|
||||
name: '',
|
||||
surname: '',
|
||||
isAcceptingTerms: false,
|
||||
contact: {
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredContactMethod: 'email',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,39 @@
|
||||
import { ContactMethods, UserSchema } from './types'
|
||||
|
||||
describe('demo scenario types', () => {
|
||||
it('should expose contact methods with capitalized labels', () => {
|
||||
expect(ContactMethods).toEqual([
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'phone', label: 'Phone' },
|
||||
{ value: 'whatsapp', label: 'Whatsapp' },
|
||||
{ value: 'sms', label: 'Sms' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should validate a complete user payload', () => {
|
||||
expect(UserSchema.safeParse({
|
||||
name: 'Alice',
|
||||
surname: 'Smith',
|
||||
isAcceptingTerms: true,
|
||||
contact: {
|
||||
email: 'alice@example.com',
|
||||
phone: '',
|
||||
preferredContactMethod: 'email',
|
||||
},
|
||||
}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid user payload', () => {
|
||||
const result = UserSchema.safeParse({
|
||||
name: 'alice',
|
||||
surname: 's',
|
||||
isAcceptingTerms: false,
|
||||
contact: {
|
||||
email: 'invalid',
|
||||
preferredContactMethod: 'email',
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,139 @@
|
||||
import type { InputFieldConfiguration } from './types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useMemo } from 'react'
|
||||
import { useAppForm } from '../..'
|
||||
import InputField from './field'
|
||||
import { InputFieldType } from './types'
|
||||
|
||||
const createConfig = (overrides: Partial<InputFieldConfiguration> = {}): InputFieldConfiguration => ({
|
||||
type: InputFieldType.textInput,
|
||||
variable: 'fieldA',
|
||||
label: 'Field A',
|
||||
required: false,
|
||||
showConditions: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
type FieldHarnessProps = {
|
||||
config: InputFieldConfiguration
|
||||
initialData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => {
|
||||
const form = useAppForm({
|
||||
defaultValues: initialData,
|
||||
onSubmit: () => {},
|
||||
})
|
||||
const Component = useMemo(() => InputField({ initialData, config }), [config, initialData])
|
||||
|
||||
return <Component form={form} />
|
||||
}
|
||||
|
||||
describe('InputField', () => {
|
||||
it('should render text input field by default', () => {
|
||||
render(<FieldHarness config={createConfig({ label: 'Prompt' })} initialData={{ fieldA: '' }} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('Prompt')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render number slider field when configured', () => {
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({
|
||||
type: InputFieldType.numberSlider,
|
||||
label: 'Temperature',
|
||||
description: 'Control randomness',
|
||||
min: 0,
|
||||
max: 1,
|
||||
})}
|
||||
initialData={{ fieldA: 0.5 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Temperature')).toBeInTheDocument()
|
||||
expect(screen.getByText('Control randomness')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select field with options when configured', () => {
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({
|
||||
type: InputFieldType.select,
|
||||
label: 'Mode',
|
||||
options: [{ value: 'safe', label: 'Safe' }],
|
||||
})}
|
||||
initialData={{ fieldA: 'safe' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Mode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render upload method field when configured', () => {
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({
|
||||
type: InputFieldType.uploadMethod,
|
||||
label: 'Upload Method',
|
||||
})}
|
||||
initialData={{ fieldA: 'local_file' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Upload Method')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the field when show conditions are not met', () => {
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({
|
||||
label: 'Hidden Input',
|
||||
showConditions: [{ variable: 'enabled', value: true }],
|
||||
})}
|
||||
initialData={{ enabled: false, fieldA: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Hidden Input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render remaining field types and fallback for unsupported type', () => {
|
||||
const scenarios: Array<{ config: InputFieldConfiguration, initialData: Record<string, unknown> }> = [
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.numberInput, label: 'Count', min: 1, max: 5 }),
|
||||
initialData: { fieldA: 2 },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.checkbox, label: 'Enable' }),
|
||||
initialData: { fieldA: false },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.inputTypeSelect, label: 'Input Type', supportFile: true }),
|
||||
initialData: { fieldA: 'text' },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.fileTypes, label: 'File Types' }),
|
||||
initialData: { fieldA: { allowedFileTypes: ['document'] } },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.options, label: 'Choices' }),
|
||||
initialData: { fieldA: ['one'] },
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />)
|
||||
expect(screen.getByText(scenario.config.label)).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({ type: 'unsupported' as InputFieldType, label: 'Unsupported' })}
|
||||
initialData={{ fieldA: '' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('Unsupported')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,17 @@
|
||||
import { InputFieldType } from './types'
|
||||
|
||||
describe('input-field scenario types', () => {
|
||||
it('should include expected input field types', () => {
|
||||
expect(Object.values(InputFieldType)).toEqual([
|
||||
'textInput',
|
||||
'numberInput',
|
||||
'numberSlider',
|
||||
'checkbox',
|
||||
'options',
|
||||
'select',
|
||||
'inputTypeSelect',
|
||||
'uploadMethod',
|
||||
'fileTypes',
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,150 @@
|
||||
import { InputFieldType } from './types'
|
||||
import { generateZodSchema } from './utils'
|
||||
|
||||
describe('input-field scenario schema generator', () => {
|
||||
it('should validate required text input with max length', () => {
|
||||
const schema = generateZodSchema([{
|
||||
type: InputFieldType.textInput,
|
||||
variable: 'prompt',
|
||||
label: 'Prompt',
|
||||
required: true,
|
||||
maxLength: 5,
|
||||
showConditions: [],
|
||||
}])
|
||||
|
||||
expect(schema.safeParse({ prompt: 'hello' }).success).toBe(true)
|
||||
expect(schema.safeParse({ prompt: '' }).success).toBe(false)
|
||||
expect(schema.safeParse({ prompt: 'longer than five' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate file types payload shape', () => {
|
||||
const schema = generateZodSchema([{
|
||||
type: InputFieldType.fileTypes,
|
||||
variable: 'files',
|
||||
label: 'Files',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
}])
|
||||
|
||||
expect(schema.safeParse({
|
||||
files: {
|
||||
allowedFileExtensions: 'txt,pdf',
|
||||
allowedFileTypes: ['document'],
|
||||
},
|
||||
}).success).toBe(true)
|
||||
|
||||
expect(schema.safeParse({
|
||||
files: {
|
||||
allowedFileTypes: ['invalid-type'],
|
||||
},
|
||||
}).success).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow optional upload method fields to be omitted', () => {
|
||||
const schema = generateZodSchema([{
|
||||
type: InputFieldType.uploadMethod,
|
||||
variable: 'methods',
|
||||
label: 'Methods',
|
||||
required: false,
|
||||
showConditions: [],
|
||||
}])
|
||||
|
||||
expect(schema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('should validate numeric bounds and other field type shapes', () => {
|
||||
const schema = generateZodSchema([
|
||||
{
|
||||
type: InputFieldType.numberInput,
|
||||
variable: 'count',
|
||||
label: 'Count',
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 3,
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: InputFieldType.numberSlider,
|
||||
variable: 'temperature',
|
||||
label: 'Temperature',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: InputFieldType.checkbox,
|
||||
variable: 'enabled',
|
||||
label: 'Enabled',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: InputFieldType.options,
|
||||
variable: 'choices',
|
||||
label: 'Choices',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: InputFieldType.select,
|
||||
variable: 'mode',
|
||||
label: 'Mode',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: InputFieldType.inputTypeSelect,
|
||||
variable: 'inputType',
|
||||
label: 'Input Type',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: InputFieldType.uploadMethod,
|
||||
variable: 'methods',
|
||||
label: 'Methods',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
},
|
||||
{
|
||||
type: 'unsupported' as InputFieldType,
|
||||
variable: 'other',
|
||||
label: 'Other',
|
||||
required: true,
|
||||
showConditions: [],
|
||||
},
|
||||
])
|
||||
|
||||
expect(schema.safeParse({
|
||||
count: 2,
|
||||
temperature: 0.5,
|
||||
enabled: true,
|
||||
choices: ['a'],
|
||||
mode: 'safe',
|
||||
inputType: 'text',
|
||||
methods: ['local_file'],
|
||||
other: { key: 'value' },
|
||||
}).success).toBe(true)
|
||||
|
||||
expect(schema.safeParse({
|
||||
count: 0,
|
||||
temperature: 0.5,
|
||||
enabled: true,
|
||||
choices: ['a'],
|
||||
mode: 'safe',
|
||||
inputType: 'text',
|
||||
methods: ['local_file'],
|
||||
other: { key: 'value' },
|
||||
}).success).toBe(false)
|
||||
|
||||
expect(schema.safeParse({
|
||||
count: 4,
|
||||
temperature: 0.5,
|
||||
enabled: true,
|
||||
choices: ['a'],
|
||||
mode: 'safe',
|
||||
inputType: 'text',
|
||||
methods: ['local_file'],
|
||||
other: { key: 'value' },
|
||||
}).success).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,145 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { InputFieldConfiguration } from './types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useMemo } from 'react'
|
||||
import { ReactFlowProvider } from 'reactflow'
|
||||
import { useAppForm } from '../..'
|
||||
import NodePanelField from './field'
|
||||
import { InputFieldType } from './types'
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
|
||||
default: () => <div>Variable Picker</div>,
|
||||
}))
|
||||
|
||||
const createConfig = (overrides: Partial<InputFieldConfiguration> = {}): InputFieldConfiguration => ({
|
||||
type: InputFieldType.textInput,
|
||||
variable: 'fieldA',
|
||||
label: 'Field A',
|
||||
required: false,
|
||||
showConditions: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
type FieldHarnessProps = {
|
||||
config: InputFieldConfiguration
|
||||
initialData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => {
|
||||
const form = useAppForm({
|
||||
defaultValues: initialData,
|
||||
onSubmit: () => {},
|
||||
})
|
||||
const Component = useMemo(() => NodePanelField({ initialData, config }), [config, initialData])
|
||||
|
||||
return <Component form={form} />
|
||||
}
|
||||
|
||||
const NodePanelWrapper = ({ children }: { children: ReactNode }) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ReactFlowProvider>
|
||||
{children}
|
||||
</ReactFlowProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('NodePanelField', () => {
|
||||
it('should render text input field', () => {
|
||||
render(<FieldHarness config={createConfig({ label: 'Node Name' })} initialData={{ fieldA: '' }} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node Name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render variable-or-constant field when configured', () => {
|
||||
render(
|
||||
<NodePanelWrapper>
|
||||
<FieldHarness
|
||||
config={createConfig({
|
||||
type: InputFieldType.variableOrConstant,
|
||||
label: 'Mode',
|
||||
})}
|
||||
initialData={{ fieldA: '' }}
|
||||
/>
|
||||
</NodePanelWrapper>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Mode')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide field when show conditions are not satisfied', () => {
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({
|
||||
label: 'Hidden Node Field',
|
||||
showConditions: [{ variable: 'enabled', value: true }],
|
||||
})}
|
||||
initialData={{ enabled: false, fieldA: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Hidden Node Field')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render other configured field types and hide unsupported type', () => {
|
||||
const scenarios: Array<{ config: InputFieldConfiguration, initialData: Record<string, unknown> }> = [
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.numberInput, label: 'Count', min: 1, max: 3 }),
|
||||
initialData: { fieldA: 2 },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.numberSlider, label: 'Temperature', description: 'Adjust' }),
|
||||
initialData: { fieldA: 0.4 },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.checkbox, label: 'Enabled' }),
|
||||
initialData: { fieldA: true },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.select, label: 'Mode', options: [{ value: 'safe', label: 'Safe' }] }),
|
||||
initialData: { fieldA: 'safe' },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.inputTypeSelect, label: 'Input Type', supportFile: true }),
|
||||
initialData: { fieldA: 'text' },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.uploadMethod, label: 'Upload Method' }),
|
||||
initialData: { fieldA: ['local_file'] },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.fileTypes, label: 'File Types' }),
|
||||
initialData: { fieldA: { allowedFileTypes: ['document'] } },
|
||||
},
|
||||
{
|
||||
config: createConfig({ type: InputFieldType.options, label: 'Options' }),
|
||||
initialData: { fieldA: ['a'] },
|
||||
},
|
||||
]
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const { unmount } = render(<FieldHarness config={scenario.config} initialData={scenario.initialData} />)
|
||||
expect(screen.getByText(scenario.config.label)).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
render(
|
||||
<FieldHarness
|
||||
config={createConfig({ type: 'unsupported' as InputFieldType, label: 'Unsupported Node' })}
|
||||
initialData={{ fieldA: '' }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByText('Unsupported Node')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,7 @@
|
||||
import { InputFieldType } from './types'
|
||||
|
||||
describe('node-panel scenario types', () => {
|
||||
it('should include variableOrConstant field type', () => {
|
||||
expect(Object.values(InputFieldType)).toContain('variableOrConstant')
|
||||
})
|
||||
})
|
||||
12
web/app/components/base/form/hooks/index.spec.ts
Normal file
12
web/app/components/base/form/hooks/index.spec.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import * as hookExports from './index'
|
||||
import { useCheckValidated } from './use-check-validated'
|
||||
import { useGetFormValues } from './use-get-form-values'
|
||||
import { useGetValidators } from './use-get-validators'
|
||||
|
||||
describe('hooks index exports', () => {
|
||||
it('should re-export all hook modules', () => {
|
||||
expect(hookExports.useCheckValidated).toBe(useCheckValidated)
|
||||
expect(hookExports.useGetFormValues).toBe(useGetFormValues)
|
||||
expect(hookExports.useGetValidators).toBe(useGetValidators)
|
||||
})
|
||||
})
|
||||
105
web/app/components/base/form/hooks/use-check-validated.spec.ts
Normal file
105
web/app/components/base/form/hooks/use-check-validated.spec.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import type { AnyFormApi } from '@tanstack/react-form'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '../types'
|
||||
import { useCheckValidated } from './use-check-validated'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useCheckValidated', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return true when form has no errors', () => {
|
||||
const form = {
|
||||
getAllErrors: () => undefined,
|
||||
state: { values: {} },
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, []))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(true)
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should notify and return false when visible field has errors', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
name: { errors: ['Name is required'] },
|
||||
},
|
||||
}),
|
||||
state: { values: {} },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Name is required',
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore hidden field errors and return true', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
secret: { errors: ['Secret is required'] },
|
||||
},
|
||||
}),
|
||||
state: { values: { enabled: 'false' } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'secret',
|
||||
label: 'Secret',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [{ variable: 'enabled', value: 'true' }],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(true)
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should notify when field is shown and has errors', () => {
|
||||
const form = {
|
||||
getAllErrors: () => ({
|
||||
fields: {
|
||||
secret: { errors: ['Secret is required'] },
|
||||
},
|
||||
}),
|
||||
state: { values: { enabled: 'true' } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'secret',
|
||||
label: 'Secret',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
show_on: [{ variable: 'enabled', value: 'true' }],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useCheckValidated(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.checkValidated()).toBe(false)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Secret is required',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,74 @@
|
||||
import type { AnyFormApi } from '@tanstack/react-form'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '../types'
|
||||
import { useGetFormValues } from './use-get-form-values'
|
||||
|
||||
const mockCheckValidated = vi.fn()
|
||||
const mockTransform = vi.fn()
|
||||
|
||||
vi.mock('./use-check-validated', () => ({
|
||||
useCheckValidated: () => ({
|
||||
checkValidated: mockCheckValidated,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/secret-input', () => ({
|
||||
getTransformedValuesWhenSecretInputPristine: (...args: unknown[]) => mockTransform(...args),
|
||||
}))
|
||||
|
||||
describe('useGetFormValues', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return raw values when validation check is disabled', () => {
|
||||
const form = {
|
||||
store: { state: { values: { name: 'Alice' } } },
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, []))
|
||||
|
||||
expect(result.current.getFormValues({ needCheckValidatedValues: false })).toEqual({
|
||||
values: { name: 'Alice' },
|
||||
isCheckValidated: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return transformed values when validation passes and transform is requested', () => {
|
||||
const form = {
|
||||
store: { state: { values: { password: 'abc123' } } },
|
||||
}
|
||||
const schemas = [{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
required: true,
|
||||
type: FormTypeEnum.secretInput,
|
||||
}]
|
||||
mockCheckValidated.mockReturnValue(true)
|
||||
mockTransform.mockReturnValue({ password: '[__HIDDEN__]' })
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, schemas))
|
||||
|
||||
expect(result.current.getFormValues({
|
||||
needCheckValidatedValues: true,
|
||||
needTransformWhenSecretFieldIsPristine: true,
|
||||
})).toEqual({
|
||||
values: { password: '[__HIDDEN__]' },
|
||||
isCheckValidated: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return empty values when validation fails', () => {
|
||||
const form = {
|
||||
store: { state: { values: { name: '' } } },
|
||||
}
|
||||
mockCheckValidated.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useGetFormValues(form as unknown as AnyFormApi, []))
|
||||
|
||||
expect(result.current.getFormValues({ needCheckValidatedValues: true })).toEqual({
|
||||
values: {},
|
||||
isCheckValidated: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,78 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { createElement } from 'react'
|
||||
import { FormTypeEnum } from '../types'
|
||||
import { useGetValidators } from './use-get-validators'
|
||||
|
||||
vi.mock('@/hooks/use-i18n', () => ({
|
||||
useRenderI18nObject: () => (obj: Record<string, string>) => obj.en_US,
|
||||
}))
|
||||
|
||||
describe('useGetValidators', () => {
|
||||
it('should create required validators when field is required without custom validators', () => {
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
const validators = result.current.getValidators({
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
|
||||
const mountMessage = validators?.onMount?.({ value: '' })
|
||||
const blurMessage = validators?.onBlur?.({ value: '' })
|
||||
|
||||
expect(mountMessage).toContain('common.errorMsg.fieldRequired')
|
||||
expect(mountMessage).toContain('"field":"Username"')
|
||||
expect(blurMessage).toContain('common.errorMsg.fieldRequired')
|
||||
})
|
||||
|
||||
it('should keep existing validators when custom validators are provided', () => {
|
||||
const customValidators = {
|
||||
onChange: vi.fn(() => 'custom error'),
|
||||
}
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
|
||||
const validators = result.current.getValidators({
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
validators: customValidators,
|
||||
})
|
||||
|
||||
expect(validators).toBe(customValidators)
|
||||
})
|
||||
|
||||
it('should fallback to field name when label is a react element', () => {
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
const validators = result.current.getValidators({
|
||||
name: 'apiKey',
|
||||
label: createElement('span', undefined, 'API Key'),
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
|
||||
const mountMessage = validators?.onMount?.({ value: '' })
|
||||
expect(mountMessage).toContain('"field":"apiKey"')
|
||||
})
|
||||
|
||||
it('should translate object labels and skip validators for non-required fields', () => {
|
||||
const { result } = renderHook(() => useGetValidators())
|
||||
|
||||
const requiredValidators = result.current.getValidators({
|
||||
name: 'workspace',
|
||||
label: { en_US: 'Workspace', zh_Hans: '工作区' },
|
||||
required: true,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
const nonRequiredValidators = result.current.getValidators({
|
||||
name: 'optionalField',
|
||||
label: 'Optional',
|
||||
required: false,
|
||||
type: FormTypeEnum.textInput,
|
||||
})
|
||||
|
||||
const changeMessage = requiredValidators?.onChange?.({ value: '' })
|
||||
expect(changeMessage).toContain('"field":"Workspace"')
|
||||
expect(nonRequiredValidators).toBeUndefined()
|
||||
})
|
||||
})
|
||||
64
web/app/components/base/form/index.spec.tsx
Normal file
64
web/app/components/base/form/index.spec.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useAppForm, withForm } from './index'
|
||||
|
||||
const FormHarness = ({ onSubmit }: { onSubmit: (value: Record<string, unknown>) => void }) => {
|
||||
const form = useAppForm({
|
||||
defaultValues: { title: 'Initial title' },
|
||||
onSubmit: ({ value }) => onSubmit(value),
|
||||
})
|
||||
|
||||
return (
|
||||
<form>
|
||||
<form.AppField
|
||||
name="title"
|
||||
children={field => <field.TextField label="Title" />}
|
||||
/>
|
||||
<form.AppForm>
|
||||
<button type="button" onClick={() => form.handleSubmit()}>
|
||||
Submit
|
||||
</button>
|
||||
</form.AppForm>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
const InlinePreview = withForm({
|
||||
defaultValues: { title: '' },
|
||||
render: ({ form }) => {
|
||||
return (
|
||||
<form.AppField
|
||||
name="title"
|
||||
children={field => <field.TextField label="Preview Title" />}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const WithFormHarness = () => {
|
||||
const form = useAppForm({
|
||||
defaultValues: { title: 'Preview value' },
|
||||
onSubmit: () => {},
|
||||
})
|
||||
|
||||
return <InlinePreview form={form} />
|
||||
}
|
||||
|
||||
describe('form index exports', () => {
|
||||
it('should submit values through the generated app form', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<FormHarness onSubmit={onSubmit} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ title: 'Initial title' })
|
||||
})
|
||||
})
|
||||
|
||||
it('should render components created with withForm', () => {
|
||||
render(<WithFormHarness />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Preview value')
|
||||
expect(screen.getByText('Preview Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
18
web/app/components/base/form/types.spec.ts
Normal file
18
web/app/components/base/form/types.spec.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { FormItemValidateStatusEnum, FormTypeEnum } from './types'
|
||||
|
||||
describe('form types', () => {
|
||||
it('should expose expected form type values', () => {
|
||||
expect(Object.values(FormTypeEnum)).toContain('text-input')
|
||||
expect(Object.values(FormTypeEnum)).toContain('dynamic-select')
|
||||
expect(Object.values(FormTypeEnum)).toContain('boolean')
|
||||
})
|
||||
|
||||
it('should expose expected validation status values', () => {
|
||||
expect(Object.values(FormItemValidateStatusEnum)).toEqual([
|
||||
'success',
|
||||
'warning',
|
||||
'error',
|
||||
'validating',
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,54 @@
|
||||
import type { AnyFormApi } from '@tanstack/react-form'
|
||||
import { FormTypeEnum } from '../../types'
|
||||
import { getTransformedValuesWhenSecretInputPristine, transformFormSchemasSecretInput } from './index'
|
||||
|
||||
describe('secret input utilities', () => {
|
||||
it('should mask only selected truthy values in transformFormSchemasSecretInput', () => {
|
||||
expect(transformFormSchemasSecretInput(['apiKey'], {
|
||||
apiKey: 'secret',
|
||||
token: 'token-value',
|
||||
emptyValue: '',
|
||||
})).toEqual({
|
||||
apiKey: '[__HIDDEN__]',
|
||||
token: 'token-value',
|
||||
emptyValue: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should mask pristine secret input fields from form state', () => {
|
||||
const formSchemas = [
|
||||
{ name: 'apiKey', type: FormTypeEnum.secretInput, label: 'API Key', required: true },
|
||||
{ name: 'name', type: FormTypeEnum.textInput, label: 'Name', required: true },
|
||||
]
|
||||
const form = {
|
||||
store: {
|
||||
state: {
|
||||
values: {
|
||||
apiKey: 'secret',
|
||||
name: 'Alice',
|
||||
},
|
||||
},
|
||||
},
|
||||
getFieldMeta: (name: string) => ({ isPristine: name === 'apiKey' }),
|
||||
}
|
||||
|
||||
expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({
|
||||
apiKey: '[__HIDDEN__]',
|
||||
name: 'Alice',
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep value unchanged when secret input is not pristine', () => {
|
||||
const formSchemas = [
|
||||
{ name: 'apiKey', type: FormTypeEnum.secretInput, label: 'API Key', required: true },
|
||||
]
|
||||
const form = {
|
||||
store: { state: { values: { apiKey: 'secret' } } },
|
||||
getFieldMeta: () => ({ isPristine: false }),
|
||||
}
|
||||
|
||||
expect(getTransformedValuesWhenSecretInputPristine(formSchemas, form as unknown as AnyFormApi)).toEqual({
|
||||
apiKey: 'secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,39 @@
|
||||
import * as z from 'zod'
|
||||
import { zodSubmitValidator } from './zod-submit-validator'
|
||||
|
||||
describe('zodSubmitValidator', () => {
|
||||
it('should return undefined for valid values', () => {
|
||||
const validator = zodSubmitValidator(z.object({
|
||||
name: z.string().min(2),
|
||||
}))
|
||||
|
||||
expect(validator({ value: { name: 'Alice' } })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return first error message per field for invalid values', () => {
|
||||
const validator = zodSubmitValidator(z.object({
|
||||
name: z.string().min(3, 'Name too short'),
|
||||
age: z.number().min(18, 'Must be adult'),
|
||||
}))
|
||||
|
||||
expect(validator({ value: { name: 'Al', age: 15 } })).toEqual({
|
||||
fields: {
|
||||
name: 'Name too short',
|
||||
age: 'Must be adult',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should ignore root-level issues without a field path', () => {
|
||||
const schema = z.object({ value: z.number() }).superRefine((_value, ctx) => {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Root error',
|
||||
path: [],
|
||||
})
|
||||
})
|
||||
const validator = zodSubmitValidator(schema)
|
||||
|
||||
expect(validator({ value: { value: 1 } })).toEqual({ fields: {} })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user