@nocobase/client-v2 2.2.0-beta.15 → 2.2.0-beta.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
- package/es/flow/actions/linkageRules.d.ts +25 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
- package/es/flow/components/FlowRoute.d.ts +2 -2
- package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
- package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
- package/es/flow/components/field-value-variable/index.d.ts +11 -0
- package/es/flow/internal/registerDeviceTypeContext.d.ts +10 -0
- package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
- package/es/flow/models/base/ActionModelCore.d.ts +4 -2
- package/es/flow/models/base/BlockGridModel.d.ts +3 -0
- package/es/flow/models/blocks/form/FormBlockModel.d.ts +6 -0
- package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
- package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
- package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
- package/es/index.d.ts +1 -0
- package/es/index.mjs +105 -111
- package/es/ui-operation/index.d.ts +15 -0
- package/es/ui-operation/ui-operation-codec.d.ts +10 -0
- package/lib/index.js +108 -114
- package/lib/locale/languageCodes.js +1 -0
- package/package.json +8 -7
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
- package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
- package/src/flow/__tests__/FlowRoute.test.tsx +126 -8
- package/src/flow/__tests__/PluginFlowEngine.test.ts +58 -0
- package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +314 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
- package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
- package/src/flow/actions/linkageRules.tsx +51 -13
- package/src/flow/common/Markdown/style.ts +4 -0
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/components/FieldAssignValueInput.tsx +38 -621
- package/src/flow/components/FlowRoute.tsx +71 -29
- package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
- package/src/flow/components/field-value-variable/dateValue.ts +223 -0
- package/src/flow/components/field-value-variable/index.ts +12 -0
- package/src/flow/index.ts +2 -0
- package/src/flow/internal/registerDeviceTypeContext.ts +39 -0
- package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
- package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
- package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
- package/src/flow/models/base/ActionModelCore.tsx +11 -2
- package/src/flow/models/base/BlockGridModel.tsx +26 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +15 -2
- package/src/flow/models/base/PageModel/PageTabModel.tsx +67 -17
- package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +509 -12
- package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +49 -0
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.addAppends.test.ts +41 -0
- package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +60 -0
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
- package/src/flow/models/blocks/form/FormBlockModel.tsx +4 -0
- package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
- package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
- package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
- package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
- package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +46 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
- package/src/flow/models/fields/JsonFieldModel.tsx +31 -8
- package/src/flow/models/fields/__tests__/JsonFieldModel.test.ts +82 -0
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -2
- package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
- package/src/index.ts +1 -0
- package/src/layout-manager/LayoutContentRoute.tsx +2 -1
- package/src/layout-manager/LayoutRoute.tsx +2 -1
- package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
- package/src/locale/languageCodes.ts +1 -0
- package/src/ui-operation/index.ts +33 -0
- package/src/ui-operation/ui-operation-codec.ts +54 -0
|
@@ -17,6 +17,8 @@ import { DateTimeNoTzPicker } from '../DateTimeNoTzFieldModel';
|
|
|
17
17
|
let capturedDatePickerProps: any;
|
|
18
18
|
let currentForm: any;
|
|
19
19
|
const mockResolveJsonTemplate = vi.fn();
|
|
20
|
+
const mockRunjs = vi.fn();
|
|
21
|
+
const mockLoggerWarn = vi.fn();
|
|
20
22
|
|
|
21
23
|
vi.mock('@nocobase/flow-engine', async (importOriginal) => {
|
|
22
24
|
const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
|
|
@@ -32,6 +34,10 @@ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
|
|
|
32
34
|
}),
|
|
33
35
|
useFlowContext: () => ({
|
|
34
36
|
resolveJsonTemplate: mockResolveJsonTemplate,
|
|
37
|
+
runjs: mockRunjs,
|
|
38
|
+
logger: {
|
|
39
|
+
warn: mockLoggerWarn,
|
|
40
|
+
},
|
|
35
41
|
}),
|
|
36
42
|
};
|
|
37
43
|
});
|
|
@@ -64,6 +70,9 @@ describe('DateTimeNoTzPicker date range limit', () => {
|
|
|
64
70
|
currentForm = undefined;
|
|
65
71
|
capturedDatePickerProps = undefined;
|
|
66
72
|
mockResolveJsonTemplate.mockReset();
|
|
73
|
+
mockRunjs.mockReset();
|
|
74
|
+
mockLoggerWarn.mockReset();
|
|
75
|
+
mockRunjs.mockResolvedValue({ success: true, value: undefined });
|
|
67
76
|
mockResolveJsonTemplate.mockImplementation(async (params) => ({
|
|
68
77
|
...params,
|
|
69
78
|
_maxDate: currentForm?.getFieldValue?.('b'),
|
|
@@ -239,4 +248,144 @@ describe('DateTimeNoTzPicker date range limit', () => {
|
|
|
239
248
|
expect(timeConfig?.disabledMinutes?.(12)).toEqual([]);
|
|
240
249
|
expect(timeConfig?.disabledSeconds?.(12, 34)).toEqual([]);
|
|
241
250
|
});
|
|
251
|
+
|
|
252
|
+
it('evaluates RunJS before applying minDate', async () => {
|
|
253
|
+
const code = 'return new Date("2026-05-10T08:09:10.000Z").toISOString();';
|
|
254
|
+
mockRunjs.mockResolvedValue({ success: true, value: '2026-05-10T08:09:10.000Z' });
|
|
255
|
+
mockResolveJsonTemplate.mockImplementation(async (params) => params);
|
|
256
|
+
|
|
257
|
+
render(<TestWrapper picker="date" showTime _minDate={{ code, version: 'v2' }} onChange={vi.fn()} value={null} />);
|
|
258
|
+
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
|
|
261
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-10 00:00:00'))).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(mockRunjs.mock.calls[0]?.[0]).toBe(code);
|
|
265
|
+
expect(mockRunjs.mock.calls[0]?.[2]).toEqual({ version: 'v2' });
|
|
266
|
+
expect(mockResolveJsonTemplate).toHaveBeenCalledWith({
|
|
267
|
+
_minDate: '2026-05-10T08:09:10.000Z',
|
|
268
|
+
_maxDate: undefined,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('supports a RunJS minDate together with a variable maxDate', async () => {
|
|
273
|
+
mockRunjs.mockResolvedValue({ success: true, value: '2026-05-10 08:00:00' });
|
|
274
|
+
mockResolveJsonTemplate.mockImplementation(async (params) => ({
|
|
275
|
+
...params,
|
|
276
|
+
_maxDate: '2026-05-12 18:30:40',
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
render(
|
|
280
|
+
<TestWrapper
|
|
281
|
+
picker="date"
|
|
282
|
+
showTime
|
|
283
|
+
_minDate={{ code: 'return "2026-05-10 08:00:00";', version: 'v2' }}
|
|
284
|
+
_maxDate={'{{ $nForm.max }}'}
|
|
285
|
+
onChange={vi.fn()}
|
|
286
|
+
value={null}
|
|
287
|
+
/>,
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
await waitFor(() => {
|
|
291
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
|
|
292
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-13 00:00:00'))).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-11 00:00:00'))).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('clears stale restrictions when RunJS execution fails', async () => {
|
|
299
|
+
mockResolveJsonTemplate.mockImplementation(async (params) => params);
|
|
300
|
+
mockRunjs.mockImplementation(async (code) => {
|
|
301
|
+
if (code === 'throw new Error("failed");') {
|
|
302
|
+
return { success: false, error: new Error('failed') };
|
|
303
|
+
}
|
|
304
|
+
return { success: true, value: '2026-05-10 08:00:00' };
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const view = render(
|
|
308
|
+
<TestWrapper
|
|
309
|
+
picker="date"
|
|
310
|
+
showTime
|
|
311
|
+
_minDate={{ code: 'return "2026-05-10 08:00:00";', version: 'v2' }}
|
|
312
|
+
onChange={vi.fn()}
|
|
313
|
+
value={null}
|
|
314
|
+
/>,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
await waitFor(() => {
|
|
318
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-09 00:00:00'))).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
view.rerender(
|
|
322
|
+
<TestWrapper
|
|
323
|
+
picker="date"
|
|
324
|
+
showTime
|
|
325
|
+
_minDate={{ code: 'throw new Error("failed");', version: 'v2' }}
|
|
326
|
+
onChange={vi.fn()}
|
|
327
|
+
value={null}
|
|
328
|
+
/>,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(mockLoggerWarn).toHaveBeenCalled();
|
|
333
|
+
expect(capturedDatePickerProps?.minDate).toBeNull();
|
|
334
|
+
expect(capturedDatePickerProps?.disabledDate).toBeNull();
|
|
335
|
+
expect(capturedDatePickerProps?.disabledTime).toBeNull();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('keeps the latest RunJS result when an earlier execution finishes later', async () => {
|
|
340
|
+
const slowResolvers: Array<(result: { success: boolean; value: string }) => void> = [];
|
|
341
|
+
mockResolveJsonTemplate.mockImplementation(async (params) => params);
|
|
342
|
+
mockRunjs.mockImplementation((code) => {
|
|
343
|
+
if (code === 'return "slow";') {
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
slowResolvers.push(resolve);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return Promise.resolve({ success: true, value: '2026-06-01 00:00:00' });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const view = render(
|
|
352
|
+
<TestWrapper
|
|
353
|
+
picker="date"
|
|
354
|
+
showTime
|
|
355
|
+
_minDate={{ code: 'return "slow";', version: 'v2' }}
|
|
356
|
+
onChange={vi.fn()}
|
|
357
|
+
value={null}
|
|
358
|
+
/>,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
await waitFor(() => {
|
|
362
|
+
expect(slowResolvers.length).toBeGreaterThan(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
view.rerender(
|
|
366
|
+
<TestWrapper
|
|
367
|
+
picker="date"
|
|
368
|
+
showTime
|
|
369
|
+
_minDate={{ code: 'return "fast";', version: 'v2' }}
|
|
370
|
+
onChange={vi.fn()}
|
|
371
|
+
value={null}
|
|
372
|
+
/>,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
await waitFor(() => {
|
|
376
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-05-31 00:00:00'))).toBe(true);
|
|
377
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-07-01 00:00:00'))).toBe(false);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
slowResolvers.forEach((resolve) => resolve({ success: true, value: '2026-12-01 00:00:00' }));
|
|
381
|
+
|
|
382
|
+
await waitFor(() => {
|
|
383
|
+
expect(mockResolveJsonTemplate).toHaveBeenCalledWith({
|
|
384
|
+
_minDate: '2026-12-01 00:00:00',
|
|
385
|
+
_maxDate: undefined,
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
expect(capturedDatePickerProps?.disabledDate?.(dayjs('2026-07-01 00:00:00'))).toBe(false);
|
|
390
|
+
});
|
|
242
391
|
});
|
|
@@ -8,140 +8,176 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { autorun } from '@formily/reactive';
|
|
11
|
-
import { Form } from 'antd';
|
|
12
|
-
import { useFlowContext } from '@nocobase/flow-engine';
|
|
11
|
+
import { Form, type FormInstance } from 'antd';
|
|
12
|
+
import { resolveRunJSObjectValues, useFlowContext } from '@nocobase/flow-engine';
|
|
13
13
|
import { dayjs } from '@nocobase/utils/client';
|
|
14
|
+
import type { ConfigType, Dayjs } from 'dayjs';
|
|
14
15
|
import { first, last } from 'lodash';
|
|
15
|
-
import
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
16
17
|
|
|
17
18
|
type DateLimitProps = {
|
|
18
|
-
_minDate?:
|
|
19
|
-
_maxDate?:
|
|
20
|
-
currentForm?:
|
|
19
|
+
_minDate?: unknown;
|
|
20
|
+
_maxDate?: unknown;
|
|
21
|
+
currentForm?: unknown;
|
|
21
22
|
};
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const [maxDate, setMaxDate] = useState<dayjs.Dayjs | null>(null);
|
|
29
|
-
const [disabledDate, setDisabledDate] = useState<any>(null);
|
|
30
|
-
const [disabledTime, setDisabledTime] = useState<any>(null);
|
|
31
|
-
const disposeRef = useRef<any>(null);
|
|
24
|
+
type DisabledTimeConfig = {
|
|
25
|
+
disabledHours: () => number[];
|
|
26
|
+
disabledMinutes: (selectedHour: number) => number[];
|
|
27
|
+
disabledSeconds: (selectedHour: number, selectedMinute: number) => number[];
|
|
28
|
+
};
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
disposeRef.current();
|
|
36
|
-
}
|
|
30
|
+
type DisabledDate = (current: Dayjs) => boolean;
|
|
31
|
+
type DisabledTime = (current: Dayjs | null) => DisabledTimeConfig;
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
function isAntdFormInstance(value: unknown): value is FormInstance {
|
|
34
|
+
if (!value || typeof value !== 'object') return false;
|
|
35
|
+
return typeof (value as Partial<FormInstance>).getFieldsValue === 'function';
|
|
36
|
+
}
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
function parseDateLimitValue(value: unknown): Dayjs | null {
|
|
39
|
+
if (!value) return null;
|
|
40
|
+
const parsed = dayjs(value as ConfigType);
|
|
41
|
+
return parsed.isValid() ? parsed : null;
|
|
42
|
+
}
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
export function useDateLimit(props: DateLimitProps) {
|
|
45
|
+
const ctx = useFlowContext();
|
|
46
|
+
const currentForm = isAntdFormInstance(props.currentForm) ? props.currentForm : undefined;
|
|
47
|
+
const currentFormValues = Form.useWatch([], currentForm);
|
|
48
|
+
const [minDate, setMinDate] = useState<Dayjs | null>(null);
|
|
49
|
+
const [maxDate, setMaxDate] = useState<Dayjs | null>(null);
|
|
50
|
+
const [disabledDate, setDisabledDate] = useState<DisabledDate | null>(null);
|
|
51
|
+
const [disabledTime, setDisabledTime] = useState<DisabledTime | null>(null);
|
|
52
|
+
const evaluationIdRef = useRef(0);
|
|
53
|
+
|
|
54
|
+
const limitDate = useCallback(async () => {
|
|
55
|
+
const evaluationId = ++evaluationIdRef.current;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const runJSResolvedParams = await resolveRunJSObjectValues(ctx, {
|
|
59
|
+
_minDate: props._minDate,
|
|
60
|
+
_maxDate: props._maxDate,
|
|
61
|
+
});
|
|
62
|
+
const resolvedParams = (await ctx.resolveJsonTemplate({
|
|
63
|
+
_minDate: runJSResolvedParams._minDate,
|
|
64
|
+
_maxDate: runJSResolvedParams._maxDate,
|
|
65
|
+
})) as { _minDate?: unknown; _maxDate?: unknown };
|
|
66
|
+
|
|
67
|
+
if (evaluationId !== evaluationIdRef.current) return;
|
|
68
|
+
|
|
69
|
+
const nextMinRaw = Array.isArray(resolvedParams?._minDate)
|
|
70
|
+
? first(resolvedParams._minDate)
|
|
71
|
+
: resolvedParams?._minDate;
|
|
72
|
+
const nextMaxRaw = Array.isArray(resolvedParams?._maxDate)
|
|
73
|
+
? last(resolvedParams._maxDate)
|
|
74
|
+
: resolvedParams?._maxDate;
|
|
75
|
+
const nextMinDate = parseDateLimitValue(nextMinRaw);
|
|
76
|
+
const nextMaxDate = parseDateLimitValue(nextMaxRaw);
|
|
77
|
+
|
|
78
|
+
setMinDate(nextMinDate);
|
|
79
|
+
setMaxDate(nextMaxDate);
|
|
80
|
+
|
|
81
|
+
const fullTimeArr = Array.from({ length: 60 }, (_, i) => i);
|
|
82
|
+
|
|
83
|
+
const nextDisabledDate: DisabledDate = (current) => {
|
|
84
|
+
if (!dayjs.isDayjs(current)) return false;
|
|
85
|
+
|
|
86
|
+
const min = nextMinDate?.startOf('day') || null;
|
|
87
|
+
const max = nextMaxDate?.endOf('day') || null;
|
|
88
|
+
|
|
89
|
+
if (min && current.startOf('day').isBefore(min)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (max && current.startOf('day').isAfter(max)) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
};
|
|
52
97
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
? last(resolvedParams._maxDate)
|
|
58
|
-
: resolvedParams?._maxDate;
|
|
59
|
-
const nextMinDate = nextMinRaw ? dayjs(nextMinRaw) : null;
|
|
60
|
-
const nextMaxDate = nextMaxRaw ? dayjs(nextMaxRaw) : null;
|
|
61
|
-
|
|
62
|
-
setMinDate(nextMinDate?.isValid?.() ? nextMinDate : null);
|
|
63
|
-
setMaxDate(nextMaxDate?.isValid?.() ? nextMaxDate : null);
|
|
64
|
-
|
|
65
|
-
const fullTimeArr = Array.from({ length: 60 }, (_, i) => i);
|
|
66
|
-
|
|
67
|
-
const nextDisabledDate = (current: dayjs.Dayjs) => {
|
|
68
|
-
if (!dayjs.isDayjs(current)) return false;
|
|
69
|
-
|
|
70
|
-
const min = nextMinDate?.isValid?.() ? nextMinDate.startOf('day') : null;
|
|
71
|
-
const max = nextMaxDate?.isValid?.() ? nextMaxDate.endOf('day') : null;
|
|
72
|
-
|
|
73
|
-
if (min && current.startOf('day').isBefore(min)) {
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
if (max && current.startOf('day').isAfter(max)) {
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
return false;
|
|
80
|
-
};
|
|
98
|
+
const nextDisabledTime: DisabledTime = (current) => {
|
|
99
|
+
if (!current || (!nextMinDate && !nextMaxDate)) {
|
|
100
|
+
return { disabledHours: () => [], disabledMinutes: () => [], disabledSeconds: () => [] };
|
|
101
|
+
}
|
|
81
102
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return { disabledHours: () => [], disabledMinutes: () => [], disabledSeconds: () => [] };
|
|
85
|
-
}
|
|
103
|
+
const isCurrentMinDay = !!nextMinDate && current.isSame(nextMinDate, 'day');
|
|
104
|
+
const isCurrentMaxDay = !!nextMaxDate && current.isSame(nextMaxDate, 'day');
|
|
86
105
|
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
const disabledHours = () => {
|
|
107
|
+
const hours = [];
|
|
108
|
+
if (isCurrentMinDay && nextMinDate) {
|
|
109
|
+
for (let hour = 0; hour < nextMinDate.hour(); hour++) {
|
|
110
|
+
hours.push(hour);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (isCurrentMaxDay && nextMaxDate) {
|
|
114
|
+
for (let hour = nextMaxDate.hour() + 1; hour < 24; hour++) {
|
|
115
|
+
hours.push(hour);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return hours;
|
|
119
|
+
};
|
|
89
120
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
for (let hour = 0; hour < nextMinDate.hour(); hour++) {
|
|
94
|
-
hours.push(hour);
|
|
121
|
+
const disabledMinutes = (selectedHour: number) => {
|
|
122
|
+
if (isCurrentMinDay && nextMinDate && selectedHour === nextMinDate.hour()) {
|
|
123
|
+
return fullTimeArr.filter((minute) => minute < nextMinDate.minute());
|
|
95
124
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
for (let hour = nextMaxDate.hour() + 1; hour < 24; hour++) {
|
|
99
|
-
hours.push(hour);
|
|
125
|
+
if (isCurrentMaxDay && nextMaxDate && selectedHour === nextMaxDate.hour()) {
|
|
126
|
+
return fullTimeArr.filter((minute) => minute > nextMaxDate.minute());
|
|
100
127
|
}
|
|
101
|
-
|
|
102
|
-
|
|
128
|
+
return [];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const disabledSeconds = (selectedHour: number, selectedMinute: number) => {
|
|
132
|
+
if (
|
|
133
|
+
isCurrentMinDay &&
|
|
134
|
+
nextMinDate &&
|
|
135
|
+
selectedHour === nextMinDate.hour() &&
|
|
136
|
+
selectedMinute === nextMinDate.minute()
|
|
137
|
+
) {
|
|
138
|
+
return fullTimeArr.filter((second) => second < nextMinDate.second());
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
isCurrentMaxDay &&
|
|
142
|
+
nextMaxDate &&
|
|
143
|
+
selectedHour === nextMaxDate.hour() &&
|
|
144
|
+
selectedMinute === nextMaxDate.minute()
|
|
145
|
+
) {
|
|
146
|
+
return fullTimeArr.filter((second) => second > nextMaxDate.second());
|
|
147
|
+
}
|
|
148
|
+
return [];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
disabledHours,
|
|
153
|
+
disabledMinutes,
|
|
154
|
+
disabledSeconds,
|
|
155
|
+
};
|
|
103
156
|
};
|
|
104
157
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (isCurrentMaxDay && nextMaxDate && selectedHour === nextMaxDate.hour()) {
|
|
110
|
-
return fullTimeArr.filter((minute) => minute > nextMaxDate.minute());
|
|
111
|
-
}
|
|
112
|
-
return [];
|
|
113
|
-
};
|
|
158
|
+
setDisabledDate(() => nextDisabledDate);
|
|
159
|
+
setDisabledTime(() => nextDisabledTime);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (evaluationId !== evaluationIdRef.current) return;
|
|
114
162
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return fullTimeArr.filter((second) => second < nextMinDate.second());
|
|
123
|
-
}
|
|
124
|
-
if (
|
|
125
|
-
isCurrentMaxDay &&
|
|
126
|
-
nextMaxDate &&
|
|
127
|
-
selectedHour === nextMaxDate.hour() &&
|
|
128
|
-
selectedMinute === nextMaxDate.minute()
|
|
129
|
-
) {
|
|
130
|
-
return fullTimeArr.filter((second) => second > nextMaxDate.second());
|
|
131
|
-
}
|
|
132
|
-
return [];
|
|
133
|
-
};
|
|
163
|
+
ctx.logger?.warn?.({ err: error }, 'Failed to resolve date range limit');
|
|
164
|
+
setMinDate(null);
|
|
165
|
+
setMaxDate(null);
|
|
166
|
+
setDisabledDate(null);
|
|
167
|
+
setDisabledTime(null);
|
|
168
|
+
}
|
|
169
|
+
}, [ctx, props._maxDate, props._minDate]);
|
|
134
170
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
};
|
|
140
|
-
};
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
const dispose = autorun(() => {
|
|
173
|
+
limitDate();
|
|
174
|
+
});
|
|
141
175
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
176
|
+
return () => {
|
|
177
|
+
evaluationIdRef.current += 1;
|
|
178
|
+
dispose();
|
|
179
|
+
};
|
|
180
|
+
}, [currentFormValues, limitDate]);
|
|
145
181
|
|
|
146
182
|
return {
|
|
147
183
|
minDate,
|
|
@@ -13,6 +13,22 @@ import JSON5 from 'json5';
|
|
|
13
13
|
import React, { useState, useEffect, useRef } from 'react';
|
|
14
14
|
import { FieldModel } from '../base/FieldModel';
|
|
15
15
|
|
|
16
|
+
const jsonValidationRuleMarker = '__jsonSyntaxValidation';
|
|
17
|
+
|
|
18
|
+
interface JsonValidationRule {
|
|
19
|
+
[jsonValidationRuleMarker]: true;
|
|
20
|
+
type: 'any';
|
|
21
|
+
validator?: (_rule: unknown, value: unknown) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRuleObject(rule: unknown): rule is Record<string, unknown> {
|
|
25
|
+
return typeof rule === 'object' && rule !== null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isJsonValidationRule(rule: unknown): rule is JsonValidationRule {
|
|
29
|
+
return isRuleObject(rule) && rule[jsonValidationRuleMarker] === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
16
32
|
const jsonCss = css`
|
|
17
33
|
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
|
18
34
|
font-size: 14px;
|
|
@@ -40,7 +56,7 @@ export const JsonInput = observer((props: any) => {
|
|
|
40
56
|
}
|
|
41
57
|
|
|
42
58
|
internalChange.current = false;
|
|
43
|
-
}, [value]);
|
|
59
|
+
}, [_JSON, space, value]);
|
|
44
60
|
|
|
45
61
|
// 用户输入时
|
|
46
62
|
const handleChange = (ev) => {
|
|
@@ -91,23 +107,30 @@ JsonFieldModel.registerFlow({
|
|
|
91
107
|
key: 'jsonInitSetting',
|
|
92
108
|
steps: {
|
|
93
109
|
initValidation: {
|
|
94
|
-
handler(ctx
|
|
95
|
-
const
|
|
110
|
+
handler(ctx) {
|
|
111
|
+
const currentRules = Array.isArray(ctx.model.parent.props.rules) ? ctx.model.parent.props.rules : [];
|
|
112
|
+
const rules = currentRules.filter((rule: unknown) => {
|
|
113
|
+
return (!isRuleObject(rule) || Object.keys(rule).length > 0) && !isJsonValidationRule(rule);
|
|
114
|
+
});
|
|
96
115
|
// 添加 JSON 语法校验规则
|
|
97
116
|
rules.push({
|
|
98
|
-
validator
|
|
117
|
+
// validator 函数序列化后会被丢弃;保留 any 类型可避免持久化规则退化为默认字符串校验。
|
|
118
|
+
type: 'any',
|
|
119
|
+
[jsonValidationRuleMarker]: true,
|
|
120
|
+
validator: (_rule: unknown, value: unknown) => {
|
|
99
121
|
// 允许空
|
|
100
122
|
if (value === null || value === undefined || value === '') return Promise.resolve();
|
|
101
123
|
// 如果已经是对象/数组,直接通过
|
|
102
124
|
if (typeof value === 'object') return Promise.resolve();
|
|
103
125
|
try {
|
|
104
|
-
JSON.parse(value);
|
|
126
|
+
JSON.parse(String(value));
|
|
105
127
|
return Promise.resolve();
|
|
106
|
-
} catch (
|
|
107
|
-
|
|
128
|
+
} catch (error) {
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
130
|
+
return Promise.reject(ctx.t('Invalid JSON format') + ': ' + message);
|
|
108
131
|
}
|
|
109
132
|
},
|
|
110
|
-
});
|
|
133
|
+
} satisfies JsonValidationRule);
|
|
111
134
|
ctx.model.parent.setProps({
|
|
112
135
|
rules,
|
|
113
136
|
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { JsonFieldModel } from '../JsonFieldModel';
|
|
13
|
+
|
|
14
|
+
interface ValidationRule {
|
|
15
|
+
required?: boolean;
|
|
16
|
+
type?: string;
|
|
17
|
+
validator?: (_rule: unknown, value: unknown) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createModels(rules: ValidationRule[]) {
|
|
21
|
+
const engine = new FlowEngine();
|
|
22
|
+
engine.registerModels({ JsonFieldModel });
|
|
23
|
+
const parent = engine.createModel<FlowModel<{ subModels: { field: JsonFieldModel } }>>({
|
|
24
|
+
use: FlowModel,
|
|
25
|
+
uid: 'json-form-item',
|
|
26
|
+
props: { rules },
|
|
27
|
+
subModels: {
|
|
28
|
+
field: {
|
|
29
|
+
use: JsonFieldModel,
|
|
30
|
+
uid: 'json-field',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return { field: parent.subModels.field, parent };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function initializeJsonValidation(field: JsonFieldModel) {
|
|
39
|
+
const step = field.getFlow('jsonInitSetting')?.steps.initValidation;
|
|
40
|
+
if (!step) {
|
|
41
|
+
throw new Error('JSON validation step is not registered');
|
|
42
|
+
}
|
|
43
|
+
step.handler(field.context, {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('JsonFieldModel validation', () => {
|
|
47
|
+
it('replaces legacy empty rules with one serializable JSON validation rule', async () => {
|
|
48
|
+
const { field, parent } = createModels([{}, { required: true }]);
|
|
49
|
+
|
|
50
|
+
initializeJsonValidation(field);
|
|
51
|
+
|
|
52
|
+
const rules = parent.props.rules as ValidationRule[];
|
|
53
|
+
expect(rules).toHaveLength(2);
|
|
54
|
+
expect(rules[0]).toEqual({ required: true });
|
|
55
|
+
expect(rules[1]).toMatchObject({ type: 'any' });
|
|
56
|
+
expect(rules[1].validator).toBeTypeOf('function');
|
|
57
|
+
if (!rules[1].validator) {
|
|
58
|
+
throw new Error('JSON validator is missing');
|
|
59
|
+
}
|
|
60
|
+
await expect(rules[1].validator({}, { name: 'NocoBase' })).resolves.toBeUndefined();
|
|
61
|
+
await expect(rules[1].validator({}, '{"name":')).rejects.toContain('Invalid JSON format');
|
|
62
|
+
|
|
63
|
+
const persistedRules = JSON.parse(JSON.stringify(parent.serialize())).props.rules as ValidationRule[];
|
|
64
|
+
expect(persistedRules).toHaveLength(2);
|
|
65
|
+
expect(persistedRules[1]).toMatchObject({ type: 'any' });
|
|
66
|
+
expect(persistedRules[1].validator).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('re-registers the runtime validator without duplicating its persisted placeholder', () => {
|
|
70
|
+
const { field, parent } = createModels([{}]);
|
|
71
|
+
initializeJsonValidation(field);
|
|
72
|
+
const persistedRules = JSON.parse(JSON.stringify(parent.serialize())).props.rules as ValidationRule[];
|
|
73
|
+
parent.setProps({ rules: persistedRules });
|
|
74
|
+
|
|
75
|
+
initializeJsonValidation(field);
|
|
76
|
+
|
|
77
|
+
const rules = parent.props.rules as ValidationRule[];
|
|
78
|
+
expect(rules).toHaveLength(1);
|
|
79
|
+
expect(rules[0]).toMatchObject({ type: 'any' });
|
|
80
|
+
expect(rules[0].validator).toBeTypeOf('function');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -158,8 +158,15 @@ vi.mock('antd-mobile', () => {
|
|
|
158
158
|
mockState.popupProps = props;
|
|
159
159
|
return props.visible ? <div data-testid="popup">{props.children}</div> : null;
|
|
160
160
|
},
|
|
161
|
-
SearchBar: ({ value, onChange }: any) => (
|
|
162
|
-
<
|
|
161
|
+
SearchBar: ({ value, onChange, onCancel, cancelText, showCancelButton }: any) => (
|
|
162
|
+
<div>
|
|
163
|
+
<input data-testid="search" value={value ?? ''} onChange={(e) => onChange?.(e.target.value)} />
|
|
164
|
+
{showCancelButton && value ? (
|
|
165
|
+
<button type="button" onClick={onCancel}>
|
|
166
|
+
{cancelText ?? '取消'}
|
|
167
|
+
</button>
|
|
168
|
+
) : null}
|
|
169
|
+
</div>
|
|
163
170
|
),
|
|
164
171
|
CheckList: MockCheckList,
|
|
165
172
|
};
|
|
@@ -289,6 +296,17 @@ describe('MobileLazySelect', () => {
|
|
|
289
296
|
resetMockState();
|
|
290
297
|
});
|
|
291
298
|
|
|
299
|
+
it('renders a translated cancel action after searching', () => {
|
|
300
|
+
renderMobileLazySelect();
|
|
301
|
+
|
|
302
|
+
openLazyPopup();
|
|
303
|
+
act(() => {
|
|
304
|
+
fireEvent.change(screen.getByTestId('search'), { target: { value: '11' } });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
|
308
|
+
});
|
|
309
|
+
|
|
292
310
|
it('keeps pending relation records selected until confirm', () => {
|
|
293
311
|
const { onChange, rerender } = renderMobileLazySelect();
|
|
294
312
|
|