diff --git a/web/app/components/base/form/form-scenarios/auth/index.spec.tsx b/web/app/components/base/form/form-scenarios/auth/index.spec.tsx new file mode 100644 index 0000000000..5560e7eada --- /dev/null +++ b/web/app/components/base/form/form-scenarios/auth/index.spec.tsx @@ -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[0]) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + {ui} + , + ) +} + +describe('AuthForm', () => { + it('should render configured fields', () => { + renderWithQueryClient() + + expect(screen.getByText('API Key')).toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() + }) + + it('should use provided default values', () => { + renderWithQueryClient() + + expect(screen.getByDisplayValue('value-123')).toBeInTheDocument() + }) + + it('should render nothing when no schema is provided', () => { + const { container } = renderWithQueryClient() + + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/web/app/components/base/form/form-scenarios/base/field.spec.tsx b/web/app/components/base/form/form-scenarios/base/field.spec.tsx new file mode 100644 index 0000000000..c05f291103 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/base/field.spec.tsx @@ -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 => ({ + type: BaseFieldType.textInput, + variable: 'fieldA', + label: 'Field A', + required: false, + showConditions: [], + ...overrides, +}) + +type FieldHarnessProps = { + config: BaseConfiguration + initialData?: Record +} + +const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => { + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const Component = useMemo(() => BaseField({ initialData, config }), [config, initialData]) + + return +} + +describe('BaseField', () => { + it('should render a text input field when configured as text input', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByText('Username')).toBeInTheDocument() + }) + + it('should render a number input when configured as number input', () => { + render() + + expect(screen.getByRole('spinbutton')).toBeInTheDocument() + expect(screen.getByText('Age')).toBeInTheDocument() + }) + + it('should render a checkbox when configured as checkbox', () => { + render() + + expect(screen.getByText('Agree')).toBeInTheDocument() + }) + + it('should render paragraph and select fields based on configuration', () => { + const scenarios: Array<{ config: BaseConfiguration, initialData: Record }> = [ + { + 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() + expect(screen.getByText(scenario.config.label)).toBeInTheDocument() + unmount() + } + }) + + it('should render file uploader when configured as file', () => { + const scenarios: Array<{ config: BaseConfiguration, initialData: Record }> = [ + { + 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() + expect(screen.getByText(scenario.config.label)).toBeInTheDocument() + unmount() + } + + render( + , + ) + expect(screen.queryByText('Unsupported')).not.toBeInTheDocument() + }) + + it('should not render when show conditions are not met', () => { + render( + , + ) + + expect(screen.queryByText('Hidden Field')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/form/form-scenarios/base/index.spec.tsx b/web/app/components/base/form/form-scenarios/base/index.spec.tsx new file mode 100644 index 0000000000..fc1aa325f2 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/base/index.spec.tsx @@ -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( + {}} + />, + ) + + 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( + ( + + )} + />, + ) + + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ name: 'Alice' }) + }) + }) + + it('should render custom actions when provided', () => { + render( + {}} + CustomActions={() => } + />, + ) + + 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( + , + ) + + 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) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/base/types.spec.ts b/web/app/components/base/form/form-scenarios/base/types.spec.ts new file mode 100644 index 0000000000..b565b5cd2a --- /dev/null +++ b/web/app/components/base/form/form-scenarios/base/types.spec.ts @@ -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', + ]) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/base/utils.spec.ts b/web/app/components/base/form/form-scenarios/base/utils.spec.ts new file mode 100644 index 0000000000..2c11acd205 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/base/utils.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/demo/contact-fields.spec.tsx b/web/app/components/base/form/form-scenarios/demo/contact-fields.spec.tsx new file mode 100644 index 0000000000..7a97d3a48b --- /dev/null +++ b/web/app/components/base/form/form-scenarios/demo/contact-fields.spec.tsx @@ -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 +} + +describe('ContactFields', () => { + it('should render contact section fields', () => { + render() + + 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() + }) +}) diff --git a/web/app/components/base/form/form-scenarios/demo/index.spec.tsx b/web/app/components/base/form/form-scenarios/demo/index.spec.tsx new file mode 100644 index 0000000000..d6534e8df7 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/demo/index.spec.tsx @@ -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() + + 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() + + 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() + 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() + 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() + 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, + })) + }) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/demo/shared-options.spec.tsx b/web/app/components/base/form/form-scenarios/demo/shared-options.spec.tsx new file mode 100644 index 0000000000..5e44747612 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/demo/shared-options.spec.tsx @@ -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', + }, + }) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/demo/types.spec.ts b/web/app/components/base/form/form-scenarios/demo/types.spec.ts new file mode 100644 index 0000000000..8e81f24c1c --- /dev/null +++ b/web/app/components/base/form/form-scenarios/demo/types.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/input-field/field.spec.tsx b/web/app/components/base/form/form-scenarios/input-field/field.spec.tsx new file mode 100644 index 0000000000..0416c1532c --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/field.spec.tsx @@ -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 => ({ + type: InputFieldType.textInput, + variable: 'fieldA', + label: 'Field A', + required: false, + showConditions: [], + ...overrides, +}) + +type FieldHarnessProps = { + config: InputFieldConfiguration + initialData?: Record +} + +const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => { + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const Component = useMemo(() => InputField({ initialData, config }), [config, initialData]) + + return +} + +describe('InputField', () => { + it('should render text input field by default', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByText('Prompt')).toBeInTheDocument() + }) + + it('should render number slider field when configured', () => { + render( + , + ) + + expect(screen.getByText('Temperature')).toBeInTheDocument() + expect(screen.getByText('Control randomness')).toBeInTheDocument() + }) + + it('should render select field with options when configured', () => { + render( + , + ) + + expect(screen.getByText('Mode')).toBeInTheDocument() + }) + + it('should render upload method field when configured', () => { + render( + , + ) + + expect(screen.getByText('Upload Method')).toBeInTheDocument() + }) + + it('should hide the field when show conditions are not met', () => { + render( + , + ) + + 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 }> = [ + { + 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() + expect(screen.getByText(scenario.config.label)).toBeInTheDocument() + unmount() + } + + render( + , + ) + expect(screen.queryByText('Unsupported')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/form/form-scenarios/input-field/types.spec.ts b/web/app/components/base/form/form-scenarios/input-field/types.spec.ts new file mode 100644 index 0000000000..b9328b2089 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/types.spec.ts @@ -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', + ]) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/input-field/utils.spec.ts b/web/app/components/base/form/form-scenarios/input-field/utils.spec.ts new file mode 100644 index 0000000000..7f91d3cd70 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/input-field/utils.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/base/form/form-scenarios/node-panel/field.spec.tsx b/web/app/components/base/form/form-scenarios/node-panel/field.spec.tsx new file mode 100644 index 0000000000..b8388206c0 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/node-panel/field.spec.tsx @@ -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: () =>
Variable Picker
, +})) + +const createConfig = (overrides: Partial = {}): InputFieldConfiguration => ({ + type: InputFieldType.textInput, + variable: 'fieldA', + label: 'Field A', + required: false, + showConditions: [], + ...overrides, +}) + +type FieldHarnessProps = { + config: InputFieldConfiguration + initialData?: Record +} + +const FieldHarness = ({ config, initialData = {} }: FieldHarnessProps) => { + const form = useAppForm({ + defaultValues: initialData, + onSubmit: () => {}, + }) + const Component = useMemo(() => NodePanelField({ initialData, config }), [config, initialData]) + + return +} + +const NodePanelWrapper = ({ children }: { children: ReactNode }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return ( + + + {children} + + + ) +} + +describe('NodePanelField', () => { + it('should render text input field', () => { + render() + + expect(screen.getByRole('textbox')).toBeInTheDocument() + expect(screen.getByText('Node Name')).toBeInTheDocument() + }) + + it('should render variable-or-constant field when configured', () => { + render( + + + , + ) + + expect(screen.getByText('Mode')).toBeInTheDocument() + }) + + it('should hide field when show conditions are not satisfied', () => { + render( + , + ) + + 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 }> = [ + { + 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() + expect(screen.getByText(scenario.config.label)).toBeInTheDocument() + unmount() + } + + render( + , + ) + expect(screen.queryByText('Unsupported Node')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/form/form-scenarios/node-panel/types.spec.ts b/web/app/components/base/form/form-scenarios/node-panel/types.spec.ts new file mode 100644 index 0000000000..8cd27eab08 --- /dev/null +++ b/web/app/components/base/form/form-scenarios/node-panel/types.spec.ts @@ -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') + }) +}) diff --git a/web/app/components/base/form/hooks/index.spec.ts b/web/app/components/base/form/hooks/index.spec.ts new file mode 100644 index 0000000000..d76743702a --- /dev/null +++ b/web/app/components/base/form/hooks/index.spec.ts @@ -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) + }) +}) diff --git a/web/app/components/base/form/hooks/use-check-validated.spec.ts b/web/app/components/base/form/hooks/use-check-validated.spec.ts new file mode 100644 index 0000000000..a8f15b403e --- /dev/null +++ b/web/app/components/base/form/hooks/use-check-validated.spec.ts @@ -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', + }) + }) +}) diff --git a/web/app/components/base/form/hooks/use-get-form-values.spec.ts b/web/app/components/base/form/hooks/use-get-form-values.spec.ts new file mode 100644 index 0000000000..163f959eff --- /dev/null +++ b/web/app/components/base/form/hooks/use-get-form-values.spec.ts @@ -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, + }) + }) +}) diff --git a/web/app/components/base/form/hooks/use-get-validators.spec.ts b/web/app/components/base/form/hooks/use-get-validators.spec.ts new file mode 100644 index 0000000000..73c7b3f86d --- /dev/null +++ b/web/app/components/base/form/hooks/use-get-validators.spec.ts @@ -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) => 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() + }) +}) diff --git a/web/app/components/base/form/index.spec.tsx b/web/app/components/base/form/index.spec.tsx new file mode 100644 index 0000000000..27dab0c9dc --- /dev/null +++ b/web/app/components/base/form/index.spec.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useAppForm, withForm } from './index' + +const FormHarness = ({ onSubmit }: { onSubmit: (value: Record) => void }) => { + const form = useAppForm({ + defaultValues: { title: 'Initial title' }, + onSubmit: ({ value }) => onSubmit(value), + }) + + return ( +
+ } + /> + + + + + ) +} + +const InlinePreview = withForm({ + defaultValues: { title: '' }, + render: ({ form }) => { + return ( + } + /> + ) + }, +}) + +const WithFormHarness = () => { + const form = useAppForm({ + defaultValues: { title: 'Preview value' }, + onSubmit: () => {}, + }) + + return +} + +describe('form index exports', () => { + it('should submit values through the generated app form', async () => { + const onSubmit = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: /submit/i })) + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith({ title: 'Initial title' }) + }) + }) + + it('should render components created with withForm', () => { + render() + + expect(screen.getByRole('textbox')).toHaveValue('Preview value') + expect(screen.getByText('Preview Title')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/form/types.spec.ts b/web/app/components/base/form/types.spec.ts new file mode 100644 index 0000000000..38d032bac7 --- /dev/null +++ b/web/app/components/base/form/types.spec.ts @@ -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', + ]) + }) +}) diff --git a/web/app/components/base/form/utils/secret-input/index.spec.ts b/web/app/components/base/form/utils/secret-input/index.spec.ts new file mode 100644 index 0000000000..c5722007b6 --- /dev/null +++ b/web/app/components/base/form/utils/secret-input/index.spec.ts @@ -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', + }) + }) +}) diff --git a/web/app/components/base/form/utils/zod-submit-validator.spec.ts b/web/app/components/base/form/utils/zod-submit-validator.spec.ts new file mode 100644 index 0000000000..74635ae844 --- /dev/null +++ b/web/app/components/base/form/utils/zod-submit-validator.spec.ts @@ -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: {} }) + }) +})