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:
akashseth-ifp
2026-02-25 08:17:25 +05:30
committed by GitHub
parent a6456da393
commit 4e142f72e8
22 changed files with 1393 additions and 0 deletions

View File

@ -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()
})
})

View 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()
})
})

View File

@ -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)
})
})

View File

@ -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',
])
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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,
}))
})
})
})

View File

@ -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',
},
})
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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',
])
})
})

View File

@ -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)
})
})

View File

@ -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()
})
})

View File

@ -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')
})
})

View 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)
})
})

View 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',
})
})
})

View File

@ -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,
})
})
})

View File

@ -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()
})
})

View 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()
})
})

View 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',
])
})
})

View File

@ -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',
})
})
})

View File

@ -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: {} })
})
})