@nocobase/client-v2 3.0.0-alpha.6 → 3.0.0-alpha.8

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.
Files changed (47) hide show
  1. package/es/flow/actions/linkageRules.d.ts +24 -0
  2. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  3. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  4. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  5. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  6. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  7. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  8. package/es/flow/models/blocks/form/FormBlockModel.d.ts +6 -0
  9. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  10. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  11. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  12. package/es/index.mjs +106 -106
  13. package/lib/index.js +97 -97
  14. package/package.json +7 -7
  15. package/src/__tests__/app.test.tsx +122 -3
  16. package/src/__tests__/browserChecker.test.ts +37 -0
  17. package/src/__tests__/settings-shell.test.tsx +27 -1
  18. package/src/components/AppComponents.tsx +16 -7
  19. package/src/components/form/table/Table.tsx +81 -6
  20. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  21. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  22. package/src/flow/actions/linkageRules.tsx +25 -7
  23. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  24. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  25. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  26. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  27. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  28. package/src/flow/components/field-value-variable/index.ts +12 -0
  29. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  30. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  31. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  32. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  33. package/src/flow/models/blocks/form/FormBlockModel.tsx +4 -0
  34. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  35. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  36. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  37. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  38. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  39. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  40. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  41. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  42. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  43. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  44. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +46 -0
  45. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  46. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -2
  47. package/src/settings-app/SettingsShell.tsx +19 -4
@@ -0,0 +1,380 @@
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 type { MetaTreeNode, VariableInputProps } from '@nocobase/flow-engine';
11
+ import { parseCtxDateExpressionConfig, serializeCtxDateExpressionConfig } from '@nocobase/flow-engine';
12
+ import { fireEvent, render, screen } from '@nocobase/test/client';
13
+ import { dayjs } from '@nocobase/utils/client';
14
+ import React from 'react';
15
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import { DateVariableEditor, serializeExactDatePickerValue } from '../DateVariableEditor';
17
+ import {
18
+ DEFAULT_DATE_VARIABLE_COMPONENT_PROPS,
19
+ isDateLikeField,
20
+ resolveDateVariableComponentProps,
21
+ type DateVariableComponentProps,
22
+ } from '../dateValue';
23
+ import { FieldValueVariableInput } from '../FieldValueVariableInput';
24
+
25
+ const mocks = vi.hoisted(() => ({
26
+ variableInputProps: undefined as VariableInputProps | undefined,
27
+ }));
28
+
29
+ vi.mock('@nocobase/flow-engine', async (importOriginal) => {
30
+ const actual = await importOriginal<typeof import('@nocobase/flow-engine')>();
31
+ return {
32
+ ...actual,
33
+ VariableInput: (props: VariableInputProps) => {
34
+ mocks.variableInputProps = props;
35
+ return <div data-testid="variable-input" />;
36
+ },
37
+ useFlowContext: () => ({
38
+ isMobileLayout: false,
39
+ model: { translate: (value: string) => value },
40
+ t: (value: string) => value,
41
+ }),
42
+ };
43
+ });
44
+
45
+ const ConstantComponent: React.FC = () => <div>constant editor</div>;
46
+ const NullComponent: React.FC = () => <div>null editor</div>;
47
+ const RunJSComponent: React.FC = () => <div>runjs editor</div>;
48
+
49
+ function renderInput(options?: {
50
+ value?: unknown;
51
+ isDateLikeField?: boolean;
52
+ dateComponentProps?: DateVariableComponentProps;
53
+ }) {
54
+ const onChange = vi.fn();
55
+ render(
56
+ <FieldValueVariableInput
57
+ value={options?.value}
58
+ onChange={onChange}
59
+ baseMetaTree={[{ name: 'currentUser', title: 'Current user', type: 'object', paths: ['currentUser'] }]}
60
+ constantComponent={ConstantComponent}
61
+ nullComponent={NullComponent}
62
+ runJSComponent={RunJSComponent}
63
+ isDateLikeField={options?.isDateLikeField ?? false}
64
+ dateComponentProps={options?.dateComponentProps ?? DEFAULT_DATE_VARIABLE_COMPONENT_PROPS}
65
+ />,
66
+ );
67
+ return onChange;
68
+ }
69
+
70
+ async function resolveMetaTree() {
71
+ const provider = mocks.variableInputProps?.metaTree;
72
+ const tree = typeof provider === 'function' ? await provider() : provider;
73
+ return (tree || []) as MetaTreeNode[];
74
+ }
75
+
76
+ describe('FieldValueVariableInput', () => {
77
+ beforeEach(() => {
78
+ mocks.variableInputProps = undefined;
79
+ });
80
+
81
+ it('adds Date as a non-selectable first-level peer with complete second-level options', async () => {
82
+ renderInput({ isDateLikeField: true });
83
+
84
+ const tree = await resolveMetaTree();
85
+ expect(tree.slice(0, 4).map((node) => node.name)).toEqual(['constant', 'null', 'date', 'runjs']);
86
+
87
+ const dateNode = tree[2];
88
+ expect(dateNode.selectable).toBe(false);
89
+ expect((dateNode.children as MetaTreeNode[]).map((node) => node.name)).toEqual([
90
+ 'exact',
91
+ 'past',
92
+ 'next',
93
+ 'now',
94
+ 'today',
95
+ 'yesterday',
96
+ 'tomorrow',
97
+ 'thisWeek',
98
+ 'lastWeek',
99
+ 'nextWeek',
100
+ 'thisMonth',
101
+ 'lastMonth',
102
+ 'nextMonth',
103
+ 'thisQuarter',
104
+ 'lastQuarter',
105
+ 'nextQuarter',
106
+ 'thisYear',
107
+ 'lastYear',
108
+ 'nextYear',
109
+ ]);
110
+ expect(tree[4].name).toBe('currentUser');
111
+ });
112
+
113
+ it('does not allow Now for pure date fields', async () => {
114
+ const dateComponentProps: DateVariableComponentProps = {
115
+ ...DEFAULT_DATE_VARIABLE_COMPONENT_PROPS,
116
+ exactNormalizeMode: 'date',
117
+ };
118
+ renderInput({ isDateLikeField: true, dateComponentProps });
119
+
120
+ const tree = await resolveMetaTree();
121
+ const dateNode = tree[2];
122
+ expect((dateNode.children as MetaTreeNode[]).map((node) => node.name)).not.toContain('now');
123
+ expect(
124
+ mocks.variableInputProps?.converters?.resolveValueFromPath?.({
125
+ name: 'now',
126
+ title: 'Now',
127
+ type: 'date',
128
+ paths: ['date', 'now'],
129
+ }),
130
+ ).toBeUndefined();
131
+ });
132
+
133
+ it('restores a legacy Now value for a pure date field without allowing it to be selected again', async () => {
134
+ const dateComponentProps: DateVariableComponentProps = {
135
+ ...DEFAULT_DATE_VARIABLE_COMPONENT_PROPS,
136
+ exactNormalizeMode: 'date',
137
+ };
138
+ renderInput({ value: '{{ ctx.date.preset.now }}', isDateLikeField: true, dateComponentProps });
139
+
140
+ const tree = await resolveMetaTree();
141
+ const dateNode = tree[2];
142
+ const nowNode = (dateNode.children as MetaTreeNode[]).find((node) => node.name === 'now');
143
+ expect(nowNode).toMatchObject({ disabled: true, selectable: false });
144
+ expect(mocks.variableInputProps?.converters?.resolvePathFromValue?.(mocks.variableInputProps.value)).toEqual([
145
+ 'date',
146
+ 'now',
147
+ ]);
148
+ });
149
+
150
+ it('restores legacy date expressions under Date without changing Constant semantics', () => {
151
+ renderInput({ value: '{{ ctx.date.preset.today }}', isDateLikeField: true });
152
+
153
+ expect(mocks.variableInputProps?.value).toMatchObject({ kind: 'preset', preset: 'today' });
154
+ expect(mocks.variableInputProps?.converters?.resolvePathFromValue?.(mocks.variableInputProps.value)).toEqual([
155
+ 'date',
156
+ 'today',
157
+ ]);
158
+ expect(
159
+ mocks.variableInputProps?.converters?.resolveValueFromPath?.({
160
+ name: 'constant',
161
+ title: 'Constant',
162
+ type: 'string',
163
+ paths: ['constant'],
164
+ }),
165
+ ).toBe('');
166
+ });
167
+
168
+ it.each([
169
+ { kind: 'exact', value: 'plain JSON value' },
170
+ { kind: 'relative', direction: 'next', amount: 1, unit: 'day' },
171
+ { kind: 'preset', preset: 'today' },
172
+ ])('keeps a JSON constant containing Date-like keys as a constant', (constantValue) => {
173
+ const onChange = renderInput({ value: constantValue, isDateLikeField: false });
174
+
175
+ expect(mocks.variableInputProps?.converters?.resolvePathFromValue?.(mocks.variableInputProps.value)).toEqual([
176
+ 'constant',
177
+ ]);
178
+ mocks.variableInputProps?.onChange?.(constantValue);
179
+ expect(onChange).toHaveBeenCalledWith(constantValue);
180
+ });
181
+
182
+ it.each(['exact', 'past', 'next', 'today'])(
183
+ 'initializes %s as a complete formatted expression for a non-date field',
184
+ (nodeName) => {
185
+ const onChange = renderInput({ isDateLikeField: false });
186
+ const initial = mocks.variableInputProps?.converters?.resolveValueFromPath?.({
187
+ name: nodeName,
188
+ title: nodeName,
189
+ type: 'date',
190
+ paths: ['date', nodeName],
191
+ });
192
+
193
+ mocks.variableInputProps?.onChange?.(initial);
194
+
195
+ const stored = onChange.mock.calls[0]?.[0];
196
+ const parsed = parseCtxDateExpressionConfig(stored);
197
+ expect(parsed).toBeDefined();
198
+ expect(parsed?.format).toBe('YYYY-MM-DD');
199
+ },
200
+ );
201
+
202
+ it('uses a datetime default Format for Now and restores a custom Format without emitting a change', () => {
203
+ const onChange = renderInput({ isDateLikeField: false });
204
+ const initial = mocks.variableInputProps?.converters?.resolveValueFromPath?.({
205
+ name: 'now',
206
+ title: 'Now',
207
+ type: 'date',
208
+ paths: ['date', 'now'],
209
+ });
210
+
211
+ mocks.variableInputProps?.onChange?.(initial);
212
+ expect(parseCtxDateExpressionConfig(onChange.mock.calls[0]?.[0])?.format).toBe('YYYY-MM-DD HH:mm:ss');
213
+
214
+ const customExpression = serializeCtxDateExpressionConfig({
215
+ kind: 'preset',
216
+ preset: 'today',
217
+ format: 'YYYY/MM/DD',
218
+ });
219
+ if (!customExpression) throw new Error('Expected a formatted Date expression');
220
+ const customOnChange = renderInput({ value: customExpression, isDateLikeField: false });
221
+
222
+ mocks.variableInputProps?.onChange?.(mocks.variableInputProps.value);
223
+ expect(mocks.variableInputProps?.value).toMatchObject({ kind: 'preset', preset: 'today', format: 'YYYY/MM/DD' });
224
+ expect(customOnChange).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('does not add the default Format while restoring a legacy expression for a non-date field', () => {
228
+ const onChange = renderInput({ value: '{{ ctx.date.preset.today }}', isDateLikeField: false });
229
+
230
+ mocks.variableInputProps?.onChange?.(mocks.variableInputProps.value);
231
+
232
+ expect(onChange).not.toHaveBeenCalled();
233
+ });
234
+
235
+ it('keeps date-like field expressions unformatted', () => {
236
+ const onChange = renderInput({ isDateLikeField: true });
237
+ const initial = mocks.variableInputProps?.converters?.resolveValueFromPath?.({
238
+ name: 'today',
239
+ title: 'Today',
240
+ type: 'date',
241
+ paths: ['date', 'today'],
242
+ });
243
+
244
+ mocks.variableInputProps?.onChange?.(initial);
245
+
246
+ expect(parseCtxDateExpressionConfig(onChange.mock.calls[0]?.[0])).toEqual({
247
+ kind: 'preset',
248
+ preset: 'today',
249
+ });
250
+
251
+ const formattedExpression = serializeCtxDateExpressionConfig({
252
+ kind: 'preset',
253
+ preset: 'today',
254
+ format: 'YYYY/MM/DD',
255
+ });
256
+ if (!formattedExpression) throw new Error('Expected a formatted Date expression');
257
+ const formattedOnChange = renderInput({ value: formattedExpression, isDateLikeField: true });
258
+
259
+ mocks.variableInputProps?.onChange?.(mocks.variableInputProps.value);
260
+ expect(mocks.variableInputProps?.value).toMatchObject({ kind: 'preset', preset: 'today', format: 'YYYY/MM/DD' });
261
+ expect(formattedOnChange).not.toHaveBeenCalled();
262
+ });
263
+
264
+ it('preserves spaces while editing a custom Format', () => {
265
+ const expression = serializeCtxDateExpressionConfig({
266
+ kind: 'preset',
267
+ preset: 'today',
268
+ format: 'YYYY-MM-DD',
269
+ });
270
+ if (!expression) throw new Error('Expected a formatted Date expression');
271
+ const onChange = renderInput({ value: expression, isDateLikeField: false });
272
+ const DateEditor = mocks.variableInputProps?.converters?.renderInputComponent?.({
273
+ name: 'today',
274
+ title: 'Today',
275
+ type: 'date',
276
+ paths: ['date', 'today'],
277
+ });
278
+ if (!DateEditor) throw new Error('Expected the Date editor');
279
+
280
+ render(<DateEditor value={mocks.variableInputProps?.value} onChange={mocks.variableInputProps?.onChange} />);
281
+ fireEvent.change(screen.getByRole('combobox', { name: 'Format' }), {
282
+ target: { value: 'YYYY-MM-DD ' },
283
+ });
284
+
285
+ expect(parseCtxDateExpressionConfig(onChange.mock.calls[0]?.[0])?.format).toBe('YYYY-MM-DD ');
286
+ });
287
+ });
288
+
289
+ describe('DateVariableEditor Format', () => {
290
+ it('stores date-only picker values without converting them to a timezone-dependent ISO instant', () => {
291
+ expect(serializeExactDatePickerValue(dayjs('2026-02-12'), false)).toBe('2026-02-12');
292
+ expect(serializeExactDatePickerValue([dayjs('2026-02-12'), dayjs('2026-02-20')], false)).toEqual([
293
+ '2026-02-12',
294
+ '2026-02-20',
295
+ ]);
296
+ });
297
+
298
+ it('shows Format for non-date fields', () => {
299
+ render(
300
+ <DateVariableEditor
301
+ value={{ kind: 'preset', preset: 'today' }}
302
+ isDateLikeField={false}
303
+ dateComponentProps={DEFAULT_DATE_VARIABLE_COMPONENT_PROPS}
304
+ />,
305
+ );
306
+
307
+ expect(screen.getByRole('combobox', { name: 'Format' })).toBeInTheDocument();
308
+ expect(screen.queryByRole('textbox', { name: 'Preview' })).not.toBeInTheDocument();
309
+ });
310
+
311
+ it('keeps the relative amount visible beside its fixed-width unit selector', () => {
312
+ render(
313
+ <DateVariableEditor
314
+ value={{ kind: 'relative', direction: 'next', amount: 1, unit: 'day' }}
315
+ isDateLikeField={false}
316
+ dateComponentProps={DEFAULT_DATE_VARIABLE_COMPONENT_PROPS}
317
+ />,
318
+ );
319
+
320
+ const amountInput = screen.getByRole('spinbutton', { name: 'Next' });
321
+ expect(amountInput).toHaveValue('1');
322
+ expect(amountInput.closest('.ant-input-number')).toHaveStyle({ minWidth: '80px' });
323
+ expect(screen.getByRole('combobox', { name: 'Unit' }).closest('.ant-select')).toHaveStyle({ flex: '0 0 130px' });
324
+ });
325
+
326
+ it('shows the default Format as a placeholder when a legacy expression has no Format', () => {
327
+ render(
328
+ <DateVariableEditor
329
+ value={{ kind: 'preset', preset: 'now' }}
330
+ isDateLikeField={false}
331
+ dateComponentProps={DEFAULT_DATE_VARIABLE_COMPONENT_PROPS}
332
+ />,
333
+ );
334
+
335
+ const formatInput = screen.getByRole('combobox', { name: 'Format' });
336
+ expect(formatInput).toHaveValue('');
337
+ expect(screen.getByText('YYYY-MM-DD HH:mm:ss')).toHaveClass('ant-select-selection-placeholder');
338
+ });
339
+
340
+ it('hides Format for date-like fields', () => {
341
+ render(
342
+ <DateVariableEditor
343
+ value={{ kind: 'preset', preset: 'today' }}
344
+ isDateLikeField
345
+ dateComponentProps={DEFAULT_DATE_VARIABLE_COMPONENT_PROPS}
346
+ />,
347
+ );
348
+
349
+ expect(screen.queryByRole('combobox', { name: 'Format' })).not.toBeInTheDocument();
350
+ });
351
+ });
352
+
353
+ describe('date-like field detection', () => {
354
+ it('treats dateOnly interface and type as pure date fields', () => {
355
+ expect(isDateLikeField({ interface: 'dateOnly' })).toBe(true);
356
+ expect(isDateLikeField({ type: 'dateOnly' })).toBe(true);
357
+ expect(resolveDateVariableComponentProps({ interface: 'dateOnly' })).toMatchObject({
358
+ showTime: false,
359
+ exactNormalizeMode: 'date',
360
+ });
361
+ expect(resolveDateVariableComponentProps({ type: 'dateOnly' })).toMatchObject({
362
+ showTime: false,
363
+ exactNormalizeMode: 'date',
364
+ });
365
+ expect(
366
+ resolveDateVariableComponentProps({
367
+ interface: 'dateOnly',
368
+ getComponentProps: () => ({ showTime: true, format: 'YYYY-MM-DD HH:mm:ss' }),
369
+ }),
370
+ ).toMatchObject({
371
+ showTime: false,
372
+ format: 'YYYY-MM-DD',
373
+ exactNormalizeMode: 'date',
374
+ });
375
+ });
376
+
377
+ it('uses the resolved fallback interface when the preferred field metadata omits it', () => {
378
+ expect(isDateLikeField({}, 'scheduledAt', 'datetime')).toBe(true);
379
+ });
380
+ });
@@ -0,0 +1,223 @@
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 { isCtxDateExpression, isRunJSValue, isVariableExpression, serializeCtxDateValue } from '@nocobase/flow-engine';
11
+ import { dayjs } from '@nocobase/utils/client';
12
+ import type { ExactDatePickerMode } from '../FieldAssignExactDatePicker';
13
+
14
+ const DATE_FIELD_INTERFACES = new Set([
15
+ 'date',
16
+ 'dateOnly',
17
+ 'datetime',
18
+ 'datetimeNoTz',
19
+ 'createdAt',
20
+ 'updatedAt',
21
+ 'unixTimestamp',
22
+ ]);
23
+ const TZ_AWARE_DATE_INTERFACES = new Set(['datetime', 'createdAt', 'updatedAt', 'unixTimestamp']);
24
+ const DATE_ONLY_OUTPUT_FORMAT = 'YYYY-MM-DD';
25
+ const DATETIME_NO_TZ_OUTPUT_FORMAT = 'YYYY-MM-DD HH:mm:ss';
26
+
27
+ type DateFieldLike = {
28
+ interface?: unknown;
29
+ type?: unknown;
30
+ getComponentProps?: () => unknown;
31
+ uiSchema?: Record<string, unknown>;
32
+ };
33
+
34
+ export type DateVariableExactNormalizeMode = 'none' | 'date' | 'datetimeNoTz' | 'iso';
35
+
36
+ export type DateVariableComponentProps = {
37
+ picker: ExactDatePickerMode;
38
+ showTime: boolean;
39
+ timeFormat: string;
40
+ format: string;
41
+ exactNormalizeMode: DateVariableExactNormalizeMode;
42
+ };
43
+
44
+ export const DEFAULT_DATE_VARIABLE_COMPONENT_PROPS: DateVariableComponentProps = {
45
+ picker: 'date',
46
+ showTime: false,
47
+ timeFormat: 'HH:mm:ss',
48
+ format: DATE_ONLY_OUTPUT_FORMAT,
49
+ exactNormalizeMode: 'none',
50
+ };
51
+
52
+ export function getFieldInterface(field: DateFieldLike | null | undefined): string {
53
+ return typeof field?.interface === 'string' ? field.interface : '';
54
+ }
55
+
56
+ function getFieldComponentProps(field: DateFieldLike | null | undefined): Record<string, unknown> {
57
+ const fromGetter = typeof field?.getComponentProps === 'function' ? field.getComponentProps() : undefined;
58
+ if (fromGetter && typeof fromGetter === 'object' && !Array.isArray(fromGetter)) {
59
+ return fromGetter as Record<string, unknown>;
60
+ }
61
+ const fromSchema = field?.uiSchema?.['x-component-props'];
62
+ return fromSchema && typeof fromSchema === 'object' && !Array.isArray(fromSchema)
63
+ ? (fromSchema as Record<string, unknown>)
64
+ : {};
65
+ }
66
+
67
+ function normalizeExactDatePickerMode(value: unknown): ExactDatePickerMode {
68
+ if (value === 'year' || value === 'quarter' || value === 'month' || value === 'date') {
69
+ return value;
70
+ }
71
+ return 'date';
72
+ }
73
+
74
+ function getDateVariableExactNormalizeMode(
75
+ fieldInterface: string,
76
+ field: DateFieldLike | null | undefined,
77
+ ): DateVariableExactNormalizeMode {
78
+ if (fieldInterface === 'date' || fieldInterface === 'dateOnly' || field?.type === 'dateOnly') return 'date';
79
+ if (fieldInterface === 'datetimeNoTz') return 'datetimeNoTz';
80
+ if (TZ_AWARE_DATE_INTERFACES.has(fieldInterface)) return 'iso';
81
+ return 'none';
82
+ }
83
+
84
+ export function isDateLikeField(
85
+ field: DateFieldLike | null | undefined,
86
+ fallbackFieldName?: string,
87
+ fallbackFieldInterface?: string,
88
+ ): boolean {
89
+ const fieldInterface = getFieldInterface(field) || fallbackFieldInterface || '';
90
+ if (DATE_FIELD_INTERFACES.has(fieldInterface) || field?.type === 'dateOnly') return true;
91
+ return fallbackFieldName === 'createdAt' || fallbackFieldName === 'updatedAt';
92
+ }
93
+
94
+ export function resolveDateVariableComponentProps(
95
+ field: DateFieldLike | null | undefined,
96
+ fieldInterface = getFieldInterface(field),
97
+ ): DateVariableComponentProps {
98
+ const componentProps = getFieldComponentProps(field);
99
+ const picker = normalizeExactDatePickerMode(componentProps.picker);
100
+ const isDateOnly = fieldInterface === 'date' || fieldInterface === 'dateOnly' || field?.type === 'dateOnly';
101
+ const inferredShowTime = ['datetime', 'datetimeNoTz', 'createdAt', 'updatedAt', 'unixTimestamp'].includes(
102
+ fieldInterface,
103
+ );
104
+ const showTime = isDateOnly
105
+ ? false
106
+ : typeof componentProps.showTime === 'boolean'
107
+ ? componentProps.showTime
108
+ : inferredShowTime;
109
+ const configuredFormat = typeof componentProps.format === 'string' ? componentProps.format : '';
110
+ const dateFormat =
111
+ typeof componentProps.dateFormat === 'string' && componentProps.dateFormat
112
+ ? componentProps.dateFormat
113
+ : configuredFormat.split(' ')[0] || DATE_ONLY_OUTPUT_FORMAT;
114
+ const timeFormat =
115
+ typeof componentProps.timeFormat === 'string' && componentProps.timeFormat ? componentProps.timeFormat : 'HH:mm:ss';
116
+ const format = isDateOnly ? dateFormat : configuredFormat || (showTime ? `${dateFormat} ${timeFormat}` : dateFormat);
117
+
118
+ return {
119
+ picker,
120
+ showTime,
121
+ timeFormat,
122
+ format,
123
+ exactNormalizeMode: getDateVariableExactNormalizeMode(fieldInterface, field),
124
+ };
125
+ }
126
+
127
+ function parseDateByFormat(value: string, format: string): dayjs.Dayjs | null {
128
+ const raw = String(value || '').trim();
129
+ if (!raw) return null;
130
+
131
+ const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/i.test(raw);
132
+ if (hasTimezone) {
133
+ const parsed = dayjs(raw);
134
+ if (parsed.isValid()) return parsed;
135
+ }
136
+
137
+ if (format) {
138
+ const strict = dayjs(raw, format, true);
139
+ if (strict.isValid()) return strict;
140
+ }
141
+
142
+ const fallback = dayjs(raw);
143
+ if (fallback.isValid()) return fallback;
144
+
145
+ if (format) {
146
+ const loose = dayjs(raw, format);
147
+ if (loose.isValid()) return loose;
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ function parseDateFromRawValue(value: unknown, format: string): dayjs.Dayjs | null {
154
+ if (dayjs.isDayjs(value)) return value;
155
+ if (value instanceof Date) {
156
+ const parsedDate = dayjs(value);
157
+ return parsedDate.isValid() ? parsedDate : null;
158
+ }
159
+ return typeof value === 'string' ? parseDateByFormat(value, format) : null;
160
+ }
161
+
162
+ function normalizeExactDateValue(
163
+ value: unknown,
164
+ options: Pick<DateVariableComponentProps, 'format' | 'showTime' | 'exactNormalizeMode'>,
165
+ ): unknown {
166
+ const parsed = parseDateFromRawValue(value, options.format);
167
+ if (!parsed?.isValid()) return value;
168
+
169
+ switch (options.exactNormalizeMode) {
170
+ case 'date':
171
+ return parsed.format(DATE_ONLY_OUTPUT_FORMAT);
172
+ case 'datetimeNoTz':
173
+ return parsed.format(options.showTime ? DATETIME_NO_TZ_OUTPUT_FORMAT : DATE_ONLY_OUTPUT_FORMAT);
174
+ case 'iso':
175
+ return parsed.toISOString();
176
+ default:
177
+ return value;
178
+ }
179
+ }
180
+
181
+ export function normalizeDateVariableExactValue(
182
+ rawValue: unknown,
183
+ options: Pick<DateVariableComponentProps, 'format' | 'showTime' | 'exactNormalizeMode'>,
184
+ ): unknown {
185
+ if (options.exactNormalizeMode === 'none') return rawValue;
186
+ if (typeof rawValue === 'string' || dayjs.isDayjs(rawValue) || rawValue instanceof Date) {
187
+ return normalizeExactDateValue(rawValue, options);
188
+ }
189
+ if (Array.isArray(rawValue)) {
190
+ return rawValue.map((item) => {
191
+ if (typeof item === 'string' || dayjs.isDayjs(item) || item instanceof Date) {
192
+ return normalizeExactDateValue(item, options);
193
+ }
194
+ return item;
195
+ });
196
+ }
197
+ return rawValue;
198
+ }
199
+
200
+ export function toExactPickerDisplayValue(
201
+ rawValue: unknown,
202
+ options: { format: string; isRange: boolean },
203
+ ): dayjs.Dayjs | [dayjs.Dayjs, dayjs.Dayjs] | null {
204
+ if (options.isRange) {
205
+ if (!Array.isArray(rawValue)) return null;
206
+ const left = parseDateFromRawValue(rawValue[0], options.format);
207
+ const right = parseDateFromRawValue(rawValue[1], options.format);
208
+ return left && right ? [left, right] : null;
209
+ }
210
+ return parseDateFromRawValue(rawValue, options.format);
211
+ }
212
+
213
+ /** Compatibility helper retained for existing callers and tests. */
214
+ export function normalizeDateVariableOutput(rawValue: unknown, options: DateVariableComponentProps): unknown {
215
+ if (rawValue === null || isRunJSValue(rawValue)) return rawValue;
216
+ if (typeof rawValue === 'string' && isVariableExpression(rawValue) && !isCtxDateExpression(rawValue)) {
217
+ return rawValue;
218
+ }
219
+ if (rawValue === '' || typeof rawValue === 'undefined') return '';
220
+
221
+ const normalized = normalizeDateVariableExactValue(rawValue, options);
222
+ return serializeCtxDateValue(normalized) || normalized;
223
+ }
@@ -0,0 +1,12 @@
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
+ export * from './dateValue';
11
+ export * from './DateVariableEditor';
12
+ export * from './FieldValueVariableInput';
@@ -22,6 +22,11 @@ import { applyUpdateRecordAction } from './UpdateRecordActionUtils';
22
22
  // import { RemoteFlowModelRenderer } from '../../FlowPage';
23
23
 
24
24
  const SETTINGS_FLOW_KEY = 'assignSettings';
25
+ const AFTER_SUCCESS_DEFAULT_PARAMS = {
26
+ successMessage: tExpr('Saved successfully'),
27
+ manualClose: false,
28
+ actionAfterSuccess: 'stay',
29
+ };
25
30
 
26
31
  function Info() {
27
32
  const ctx = useFlowSettingsContext();
@@ -101,6 +106,10 @@ UpdateRecordActionModel.registerFlow({
101
106
  tipComponent: Info,
102
107
  validateBeforeSave: true,
103
108
  }),
109
+ afterSuccess: {
110
+ use: 'afterSuccess',
111
+ defaultParams: AFTER_SUCCESS_DEFAULT_PARAMS,
112
+ },
104
113
  },
105
114
  });
106
115
 
@@ -113,7 +122,15 @@ UpdateRecordActionModel.registerFlow({
113
122
  return getAssignFieldValuesDefaultParams(ctx, SETTINGS_FLOW_KEY);
114
123
  },
115
124
  async handler(ctx, params) {
116
- await applyUpdateRecordAction(ctx, params, { settingsFlowKey: SETTINGS_FLOW_KEY });
125
+ const updated = await applyUpdateRecordAction(ctx, params, { settingsFlowKey: SETTINGS_FLOW_KEY });
126
+ if (!updated) {
127
+ return;
128
+ }
129
+ const savedAfterSuccess = ctx.model.getStepParams(SETTINGS_FLOW_KEY, 'afterSuccess') || {};
130
+ await ctx.runAction('afterSuccess', {
131
+ ...AFTER_SUCCESS_DEFAULT_PARAMS,
132
+ ...savedAfterSuccess,
133
+ });
117
134
  },
118
135
  },
119
136
  },