From fdd3b4409ee7ea301afa30b364b8ca59aed48503 Mon Sep 17 00:00:00 2001 From: PiEgg Date: Sun, 12 Jul 2026 08:51:35 +0800 Subject: [PATCH] fix(plugin-workflow-delay): validate minimum duration (#10056) --- .../components/form/TypedVariableInput.tsx | 20 +++-- .../__tests__/TypedVariableInput.test.tsx | 47 +++++++++++- .../__tests__/delayFieldset.test.tsx | 73 ++++++++++++++++++- .../client-v2/components/DelayFieldset.tsx | 20 ++++- .../src/locale/en-US.json | 3 +- .../src/locale/zh-CN.json | 3 +- 6 files changed, 150 insertions(+), 16 deletions(-) diff --git a/packages/core/client-v2/src/components/form/TypedVariableInput.tsx b/packages/core/client-v2/src/components/form/TypedVariableInput.tsx index da6dcdc3998..652dd89176c 100644 --- a/packages/core/client-v2/src/components/form/TypedVariableInput.tsx +++ b/packages/core/client-v2/src/components/form/TypedVariableInput.tsx @@ -121,12 +121,15 @@ function normalizeTypes(types: TypedConstantSpec[]): NormalizedType[] { ); } -function defaultValueFor(type: TypedConstantType): unknown { +function defaultValueFor(type: TypedConstantType, typedProps: Record = {}): unknown { switch (type) { case 'string': return ''; - case 'number': + case 'number': { + const min = typeof typedProps.min === 'number' ? typedProps.min : undefined; + if (min !== undefined && min > 0) return min; return 0; + } case 'boolean': return false; case 'date': { @@ -433,7 +436,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) { return undefined; } const firstType = normalizedTypes[0]; - return firstType ? defaultValueFor(firstType.type) : undefined; + return firstType ? defaultValueFor(firstType.type, firstType.props) : undefined; }, [defaultToFirstConstantTypeWhenUndefined, normalizedTypes, value, variableOnly]); const effectiveValue = value === undefined && defaultedValue !== undefined ? defaultedValue : value; const detected = useMemo(() => detectMode(effectiveValue, parseVariablePath), [effectiveValue, parseVariablePath]); @@ -529,7 +532,8 @@ export function TypedVariableInput(props: TypedVariableInputProps) { const targetType = (path[1] as TypedConstantType | undefined) ?? normalizedTypes[0]?.type; if (!targetType) return; if (detected.mode === targetType) return; - onChange?.(defaultValueFor(targetType)); + const target = normalizedTypes.find(({ type }) => type === targetType); + onChange?.(defaultValueFor(targetType, target?.props)); return; } const leaf = selectedOptions?.[selectedOptions.length - 1] as SwitcherOption | undefined; @@ -548,7 +552,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) { } const first = normalizedTypes[0]; if (first) { - onChange?.(defaultValueFor(first.type)); + onChange?.(defaultValueFor(first.type, first.props)); return; } if (nullable) { @@ -585,11 +589,11 @@ export function TypedVariableInput(props: TypedVariableInputProps) { if (variableOnly) { return undefined; } - if (isNull) { + if (isNull && nullable) { return [NULL_KEY]; } return [CONST_KEY, constantTypeForRendering]; - }, [constantTypeForRendering, detected.variablePath, isNull, isVariable, variableOnly]); + }, [constantTypeForRendering, detected.variablePath, isNull, isVariable, nullable, variableOnly]); // Preload a saved variable's label path across lazy levels. `resolveVariableLabels` can only read already-loaded // `children`; when a saved reference points below a node whose children are still a lazy thunk (e.g. a relation field @@ -734,7 +738,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) { ) : variableOnly ? ( - ) : isNull ? ( + ) : isNull && nullable ? ( // v1 used the `placeholder` slot (not `value`) so the antd default placeholder colour applies — keeps the // field looking visibly empty/inactive rather than holding a real text value. `} readOnly disabled={disabled} style={{ width: '100%' }} /> diff --git a/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx index 3a4cb318e1f..6efc3f018be 100644 --- a/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx +++ b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx @@ -81,6 +81,27 @@ describe('TypedVariableInput - constant rendering', () => { expect(screen.getByRole('button', { name: 'variable-switcher' }).className).not.toContain('ant-btn-primary'); }); + it('keeps the number editor usable when value=null and nullable=false', async () => { + const ctx = createContextWithEnv(); + const handleChange = vi.fn(); + renderWithCtx( + ctx, + , + ); + + const numberInput = await screen.findByRole('spinbutton'); + expect(screen.queryByPlaceholderText('')).toBeNull(); + fireEvent.change(numberInput, { target: { value: '2' } }); + fireEvent.blur(numberInput); + expect(handleChange).toHaveBeenCalledWith(2); + }); + it('defaults undefined to the first constant type', async () => { const ctx = createContextWithEnv(); const handleChange = vi.fn(); @@ -102,6 +123,26 @@ describe('TypedVariableInput - constant rendering', () => { }); }); + it('uses a positive numeric minimum as the default value', async () => { + const ctx = createContextWithEnv(); + const handleChange = vi.fn(); + renderWithCtx( + ctx, + , + ); + + expect(await screen.findByDisplayValue('1')).toBeInTheDocument(); + await waitFor(() => { + expect(handleChange).toHaveBeenCalledWith(1); + }); + }); + it('can still opt out to keep the null placeholder for undefined', async () => { const ctx = createContextWithEnv(); renderWithCtx( @@ -183,14 +224,14 @@ describe('TypedVariableInput - variable rendering', () => { expect(handleChange).toHaveBeenCalledWith(0); }); - it('clears back to default-of-first-type when nullable=false', async () => { + it('clears back to the valid minimum of the first type when nullable=false', async () => { const ctx = createContextWithEnv(); const handleChange = vi.fn(); const { container } = renderWithCtx( ctx, { const clear = container.querySelector('button.clear-button') as HTMLButtonElement | null; expect(clear).not.toBeNull(); fireEvent.click(clear as HTMLButtonElement); - expect(handleChange).toHaveBeenCalledWith(0); + expect(handleChange).toHaveBeenCalledWith(1); }); it('treats types=[] as variable-only mode with a readonly placeholder before selection', async () => { diff --git a/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/__tests__/delayFieldset.test.tsx b/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/__tests__/delayFieldset.test.tsx index 21819c3d038..3758a091d9b 100644 --- a/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/__tests__/delayFieldset.test.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/__tests__/delayFieldset.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { Form } from 'antd'; import { DelayFieldset } from '../components/DelayFieldset'; @@ -62,6 +62,16 @@ function renderWithForm(initialValues?: Record) { return () => formRef; } +type TestForm = ReturnType[0]; + +function requireForm(getForm: () => TestForm | undefined): TestForm { + const form = getForm(); + if (!form) { + throw new Error('Expected the test form to be rendered'); + } + return form; +} + describe('DelayFieldset', () => { it('binds the duration input to config.duration and preserves the typed-variable options', () => { renderWithForm({ @@ -84,4 +94,65 @@ describe('DelayFieldset', () => { expect(getForm()?.getFieldValue(['config', 'endStatus'])).toBe(1); }); }); + + it('rejects an empty duration', async () => { + const form = requireForm(renderWithForm()); + let validationError: unknown; + + await act(async () => { + form.setFieldValue(['config', 'duration'], null); + try { + await form.validateFields(); + } catch (error) { + validationError = error; + } + }); + + expect(validationError).toMatchObject({ + errorFields: [ + expect.objectContaining({ + name: ['config', 'duration'], + }), + ], + }); + }); + + it('rejects a numeric duration below the minimum', async () => { + const form = requireForm(renderWithForm()); + let validationError: unknown; + + await act(async () => { + form.setFieldValue(['config', 'duration'], 0); + try { + await form.validateFields(); + } catch (error) { + validationError = error; + } + }); + + expect(validationError).toMatchObject({ + errorFields: [ + expect.objectContaining({ + name: ['config', 'duration'], + errors: ['Duration must be at least 1'], + }), + ], + }); + }); + + it('accepts a workflow variable duration', async () => { + const form = requireForm(renderWithForm()); + let values: Record | undefined; + + await act(async () => { + form.setFieldValue(['config', 'duration'], '{{$context.data.duration}}'); + values = await form.validateFields(); + }); + + expect(values).toMatchObject({ + config: { + duration: '{{$context.data.duration}}', + }, + }); + }); }); diff --git a/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/components/DelayFieldset.tsx b/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/components/DelayFieldset.tsx index f4d35af9d61..9d5f8a4c723 100644 --- a/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/components/DelayFieldset.tsx +++ b/packages/plugins/@nocobase/plugin-workflow-delay/src/client-v2/components/DelayFieldset.tsx @@ -18,6 +18,8 @@ const JOB_STATUS = { FAILED: -1, } as const; +const MIN_DURATION = 1; + const DURATION_UNIT_OPTIONS = [ { value: 1_000, key: 'Seconds' }, { value: 60_000, key: 'Minutes' }, @@ -45,9 +47,23 @@ export function DelayFieldset() { }))} /> - + { + if (typeof value === 'number' && value < MIN_DURATION) { + throw new Error(t('Duration must be at least 1')); + } + }, + }, + ]} + >