Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions web/app/components/base/form/form-scenarios/auth/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 137 additions & 0 deletions web/app/components/base/form/form-scenarios/base/field.spec.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
94 changes: 94 additions & 0 deletions web/app/components/base/form/form-scenarios/base/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
})
15 changes: 15 additions & 0 deletions web/app/components/base/form/form-scenarios/base/types.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
])
})
})
49 changes: 49 additions & 0 deletions web/app/components/base/form/form-scenarios/base/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading