fix(plugin-workflow-delay): validate minimum duration (#10056)

This commit is contained in:
PiEgg
2026-07-12 08:51:35 +08:00
committed by GitHub
parent a125c661fe
commit fdd3b4409e
6 changed files with 150 additions and 16 deletions
@@ -121,12 +121,15 @@ function normalizeTypes(types: TypedConstantSpec[]): NormalizedType[] {
);
}
function defaultValueFor(type: TypedConstantType): unknown {
function defaultValueFor(type: TypedConstantType, typedProps: Record<string, unknown> = {}): 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) {
</div>
) : variableOnly ? (
<Input placeholder={placeholder} readOnly disabled={disabled} style={{ width: '100%' }} />
) : 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.
<Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
@@ -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,
<TypedVariableInput
value={null}
types={[['number', { min: 1 }]]}
namespaces={['$env']}
nullable={false}
onChange={handleChange}
/>,
);
const numberInput = await screen.findByRole('spinbutton');
expect(screen.queryByPlaceholderText('<Null>')).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,
<TypedVariableInput
value={undefined}
types={[['number', { min: 1 }]]}
namespaces={['$env']}
nullable={false}
onChange={handleChange}
/>,
);
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,
<TypedVariableInput
value="{{$env.SMTP_PORT}}"
types={['number']}
types={[['number', { min: 1 }]]}
namespaces={['$env']}
nullable={false}
onChange={handleChange}
@@ -199,7 +240,7 @@ describe('TypedVariableInput - variable rendering', () => {
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 () => {
@@ -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<string, unknown>) {
return () => formRef;
}
type TestForm = ReturnType<typeof Form.useForm>[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<string, unknown> | undefined;
await act(async () => {
form.setFieldValue(['config', 'duration'], '{{$context.data.duration}}');
values = await form.validateFields();
});
expect(values).toMatchObject({
config: {
duration: '{{$context.data.duration}}',
},
});
});
});
@@ -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() {
}))}
/>
</Form.Item>
<Form.Item name={['config', 'duration']} noStyle initialValue={1} rules={[{ required: true }]}>
<Form.Item
name={['config', 'duration']}
noStyle
initialValue={MIN_DURATION}
rules={[
{ required: true },
{
validator: async (_, value: unknown) => {
if (typeof value === 'number' && value < MIN_DURATION) {
throw new Error(t('Duration must be at least 1'));
}
},
},
]}
>
<WorkflowTypedVariableInput
types={[['number', { min: 1 }]]}
types={[['number', { min: MIN_DURATION }]]}
nullable={false}
defaultToFirstConstantTypeWhenUndefined
placeholder={t('Duration')}
@@ -2,9 +2,10 @@
"Delay": "Delay",
"Delay a period of time and then continue or exit the process. Can be used to set wait or timeout times in parallel branches.": "Delay a period of time and then continue or exit the process. Can be used to set wait or timeout times in parallel branches.",
"Duration": "Duration",
"Duration must be at least 1": "Duration must be at least 1",
"End status": "End status",
"Fail and exit": "Fail and exit",
"Select status": "Select status",
"Succeed and continue": "Succeed and continue",
"Unit": "Unit"
}
}
@@ -2,9 +2,10 @@
"Delay": "延时",
"Delay a period of time and then continue or exit the process. Can be used to set wait or timeout times in parallel branches.": "延时一段时间,然后继续或退出流程。可以用于并行分支中等待其他分支或设置超时时间。",
"Duration": "时长",
"Duration must be at least 1": "时长不能小于 1",
"End status": "到时状态",
"Fail and exit": "失败并退出",
"Select status": "选择状态",
"Succeed and continue": "通过并继续",
"Unit": "单位"
}
}