mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-30 16:56:07 +08:00
fix(plugin-workflow-delay): validate minimum duration (#10056)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
+72
-1
@@ -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
-2
@@ -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": "单位"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user