diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 0265e0441..7bdeba5e5 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -37,6 +37,10 @@ jobs: working-directory: web run: pnpm install --frozen-lockfile + - name: Run frontend unit tests + working-directory: web + run: pnpm test:unit + - name: Install Playwright browsers working-directory: web run: pnpm exec playwright install --with-deps chromium diff --git a/web/package.json b/web/package.json index 3c3903d4b..be15b84af 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test:unit": "node --test tests/unit/*.test.mjs", "test:e2e": "playwright test", "lint": "eslint .", "format": "prettier --write ." diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 2c91e93f1..b233c25f0 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -15,6 +15,7 @@ import { FormMessage, } from '@/components/ui/form'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; +import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; import QrCodeLoginDialog, { QrLoginPlatform, } from '@/app/home/components/qrcode-login/QrCodeLoginDialog'; @@ -631,12 +632,9 @@ export default function DynamicFormComponent({ // even if the user saves without modifying any field. // form.watch(callback) only fires on subsequent changes, not on mount. const formValues = form.getValues(); - const initialFinalValues = editableValueSpecs.reduce( - (acc, item) => { - acc[item.name] = formValues[item.name] ?? item.default; - return acc; - }, - {} as Record, + const initialFinalValues = normalizeDynamicFormValuesForSave( + editableValueSpecs, + formValues as Record, ); onSubmitRef.current?.(initialFinalValues); @@ -651,12 +649,9 @@ export default function DynamicFormComponent({ const subscription = form.watch(() => { const formValues = form.getValues(); - const finalValues = editableValueSpecs.reduce( - (acc, item) => { - acc[item.name] = formValues[item.name] ?? item.default; - return acc; - }, - {} as Record, + const finalValues = normalizeDynamicFormValuesForSave( + editableValueSpecs, + formValues as Record, ); onSubmitRef.current?.(finalValues); previousInitialValues.current = finalValues as Record; diff --git a/web/src/app/home/components/dynamic-form/DynamicFormSaveValues.ts b/web/src/app/home/components/dynamic-form/DynamicFormSaveValues.ts new file mode 100644 index 000000000..e8fe38fef --- /dev/null +++ b/web/src/app/home/components/dynamic-form/DynamicFormSaveValues.ts @@ -0,0 +1,25 @@ +import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; + +export type DynamicFormSaveValueSpec = Pick< + IDynamicFormItemSchema, + 'default' | 'name' | 'type' +>; + +/** + * Build the value snapshot emitted to parent forms for persistence. + * Only single-line string fields trim surrounding whitespace; multiline text + * and every other dynamic form field type preserve their original values. + */ +export function normalizeDynamicFormValuesForSave( + specs: readonly DynamicFormSaveValueSpec[], + formValues: Record, +): Record { + return specs.reduce>((values, spec) => { + const value = formValues[spec.name] ?? spec.default; + values[spec.name] = + spec.type === 'string' && typeof value === 'string' + ? value.trim() + : value; + return values; + }, {}); +} diff --git a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx index f519d65fe..6d80b659b 100644 --- a/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/N8nAuthFormComponent.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/form'; import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; +import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues'; import { extractI18nObject } from '@/i18n/I18nProvider'; /** @@ -150,12 +151,9 @@ export default function N8nAuthFormComponent({ // Emit initial form values on mount so the parent form's // initializedStagesRef registers this stage (matches DynamicFormComponent). const formValues = form.getValues(); - const initialFinalValues = itemConfigList.reduce( - (acc, item) => { - acc[item.name] = formValues[item.name] ?? item.default; - return acc; - }, - {} as Record, + const initialFinalValues = normalizeDynamicFormValuesForSave( + itemConfigList, + formValues as Record, ); onSubmitRef.current?.(initialFinalValues); previousInitialValues.current = initialFinalValues as Record< @@ -171,12 +169,9 @@ export default function N8nAuthFormComponent({ // 获取完整的表单值,确保包含所有默认值 const formValues = form.getValues(); - const finalValues = itemConfigList.reduce( - (acc, item) => { - acc[item.name] = formValues[item.name] ?? item.default; - return acc; - }, - {} as Record, + const finalValues = normalizeDynamicFormValuesForSave( + itemConfigList, + formValues as Record, ); onSubmitRef.current?.(finalValues); diff --git a/web/tests/unit/dynamic-form-save-values.test.mjs b/web/tests/unit/dynamic-form-save-values.test.mjs new file mode 100644 index 000000000..bc314c9fa --- /dev/null +++ b/web/tests/unit/dynamic-form-save-values.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import ts from 'typescript'; +import { fileURLToPath } from 'node:url'; + +const currentDirectory = path.dirname(fileURLToPath(import.meta.url)); +const sourcePath = path.resolve( + currentDirectory, + '../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts', +); + +function loadNormalizer() { + const source = fs.readFileSync(sourcePath, 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS }, + }).outputText; + const loadedModule = { exports: {} }; + new Function('require', 'module', 'exports', compiled)( + () => { + throw new Error('DynamicFormSaveValues must not have runtime imports'); + }, + loadedModule, + loadedModule.exports, + ); + return loadedModule.exports.normalizeDynamicFormValuesForSave; +} + +test('normalizes only single-line text fields in a dynamic form save snapshot', () => { + const normalizeDynamicFormValuesForSave = loadNormalizer(); + const specs = [ + { name: 'single-line', type: 'string', default: '' }, + { name: 'multiline', type: 'text', default: '' }, + { name: 'string-list', type: 'array[string]', default: [] }, + { name: 'count', type: 'integer', default: 0 }, + ]; + const values = { + 'single-line': '\t hello world \n', + multiline: ' keep multiline whitespace \n', + 'string-list': [' first ', ' second '], + count: 3, + }; + + assert.deepEqual(normalizeDynamicFormValuesForSave(specs, values), { + 'single-line': 'hello world', + multiline: ' keep multiline whitespace \n', + 'string-list': [' first ', ' second '], + count: 3, + }); +});