@nocobase/client-v2 3.0.0-alpha.1 → 3.0.0-alpha.12

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 (133) hide show
  1. package/es/APIClient.d.ts +12 -0
  2. package/es/acl/aclCheckReadiness.d.ts +11 -0
  3. package/es/authRedirect.d.ts +6 -2
  4. package/es/flow/actions/linkageRules.d.ts +24 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +4 -30
  6. package/es/flow/components/field-value-variable/DateVariableEditor.d.ts +24 -0
  7. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +31 -0
  8. package/es/flow/components/field-value-variable/dateValue.d.ts +36 -0
  9. package/es/flow/components/field-value-variable/index.d.ts +11 -0
  10. package/es/flow/components/placeholders/BlockPlaceholder.d.ts +1 -0
  11. package/es/flow/internal/utils/operatorSchemaHelper.d.ts +2 -2
  12. package/es/flow/models/actions/UpdateRecordActionUtils.d.ts +8 -2
  13. package/es/flow/models/base/CollectionBlockModel.d.ts +3 -0
  14. package/es/flow/models/blocks/details/DetailsBlockModel.d.ts +3 -0
  15. package/es/flow/models/blocks/filter-form/FilterFormGridModel.d.ts +1 -0
  16. package/es/flow/models/blocks/filter-form/FilterFormSubmitActionModel.d.ts +3 -1
  17. package/es/flow/models/blocks/form/FormBlockModel.d.ts +7 -0
  18. package/es/flow/models/blocks/form/FormGridModel.d.ts +6 -0
  19. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +1 -0
  20. package/es/flow/models/blocks/table/JSColumnModel.d.ts +2 -0
  21. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableColumnModel.d.ts +11 -0
  23. package/es/flow/models/blocks/table/utils.d.ts +1 -0
  24. package/es/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.d.ts +2 -1
  25. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +0 -17
  26. package/es/flow/models/fields/AssociationFieldModel/recordSelectShared.d.ts +41 -0
  27. package/es/flow-compat/passwordUtils.d.ts +1 -1
  28. package/es/index.d.ts +2 -1
  29. package/es/index.mjs +140 -140
  30. package/es/nocobase-buildin-plugin/index.d.ts +1 -0
  31. package/es/settings-app/SettingsGroupNav.d.ts +3 -2
  32. package/es/settings-app/SettingsSearch.d.ts +1 -1
  33. package/es/ui-operation/index.d.ts +15 -0
  34. package/es/ui-operation/ui-operation-codec.d.ts +10 -0
  35. package/lib/index.js +145 -145
  36. package/package.json +7 -7
  37. package/src/APIClient.ts +92 -0
  38. package/src/Application.tsx +3 -1
  39. package/src/__tests__/APIClient.test.tsx +58 -0
  40. package/src/__tests__/app.test.tsx +122 -3
  41. package/src/__tests__/authRedirect.test.ts +17 -0
  42. package/src/__tests__/browserChecker.test.ts +81 -33
  43. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +37 -0
  44. package/src/__tests__/settings-center.test.tsx +312 -11
  45. package/src/__tests__/settings-layout-root.test.tsx +346 -6
  46. package/src/__tests__/settings-runtime-paths.test.ts +1 -1
  47. package/src/__tests__/settings-search-shortcut.test.tsx +321 -0
  48. package/src/__tests__/settings-shell.test.tsx +62 -1
  49. package/src/acl/ACLProvider.tsx +34 -3
  50. package/src/acl/__tests__/ACLProvider.test.tsx +92 -0
  51. package/src/acl/aclCheckReadiness.ts +44 -0
  52. package/src/authRedirect.ts +13 -5
  53. package/src/components/AppComponents.tsx +16 -7
  54. package/src/components/form/filter/CollectionFilterItem.tsx +4 -2
  55. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +17 -0
  56. package/src/components/form/table/Table.tsx +81 -6
  57. package/src/components/form/table/__tests__/Table.columnWidth.test.tsx +433 -0
  58. package/src/flow/actions/__tests__/linkageRules.actionStates.test.ts +33 -0
  59. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +72 -0
  60. package/src/flow/actions/linkageRules.tsx +33 -10
  61. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +78 -59
  62. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +228 -0
  63. package/src/flow/components/FieldAssignValueInput.tsx +38 -621
  64. package/src/flow/components/field-value-variable/DateVariableEditor.tsx +181 -0
  65. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +326 -0
  66. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +380 -0
  67. package/src/flow/components/field-value-variable/dateValue.ts +223 -0
  68. package/src/flow/components/field-value-variable/index.ts +12 -0
  69. package/src/flow/components/filter/VariableFilterItem.tsx +12 -3
  70. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +125 -0
  71. package/src/flow/components/placeholders/BlockPlaceholder.tsx +70 -23
  72. package/src/flow/components/placeholders/__tests__/BlockPlaceholder.test.tsx +57 -7
  73. package/src/flow/internal/utils/operatorSchemaHelper.ts +3 -2
  74. package/src/flow/models/actions/UpdateRecordActionModel.tsx +18 -1
  75. package/src/flow/models/actions/UpdateRecordActionUtils.ts +18 -6
  76. package/src/flow/models/actions/__tests__/UpdateRecordActionModel.test.ts +76 -4
  77. package/src/flow/models/base/CollectionBlockModel.tsx +32 -1
  78. package/src/flow/models/base/__tests__/CollectionBlockModel.initialBeforeRenderRefresh.test.ts +125 -9
  79. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +28 -53
  80. package/src/flow/models/blocks/details/DetailsBlockModel.tsx +8 -3
  81. package/src/flow/models/blocks/details/__tests__/DetailsBlockModel.initialPaginationChangeRefresh.test.ts +32 -1
  82. package/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +36 -5
  83. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +129 -14
  84. package/src/flow/models/blocks/filter-form/FilterFormSubmitActionModel.tsx +57 -4
  85. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +174 -1
  86. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +359 -1
  87. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +72 -0
  88. package/src/flow/models/blocks/filter-form/__tests__/FilterFormSubmitActionModel.lifecycle.test.ts +90 -0
  89. package/src/flow/models/blocks/filter-manager/FilterManager.ts +5 -0
  90. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +40 -0
  91. package/src/flow/models/blocks/form/FormBlockModel.tsx +47 -0
  92. package/src/flow/models/blocks/form/FormGridModel.tsx +4 -0
  93. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +8 -2
  94. package/src/flow/models/blocks/form/__tests__/runJsFormSubmit.test.ts +131 -0
  95. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +435 -0
  96. package/src/flow/models/blocks/form/value-runtime/rules.ts +67 -14
  97. package/src/flow/models/blocks/form/value-runtime/runtime.ts +43 -19
  98. package/src/flow/models/blocks/table/JSColumnModel.tsx +10 -1
  99. package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -1
  100. package/src/flow/models/blocks/table/TableColumnModel.tsx +18 -8
  101. package/src/flow/models/blocks/table/__tests__/JSColumnModel.test.tsx +52 -5
  102. package/src/flow/models/blocks/table/__tests__/TableBlockModel.filterReset.test.ts +78 -0
  103. package/src/flow/models/blocks/table/utils.ts +7 -0
  104. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +33 -1
  105. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +8 -0
  106. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/__tests__/popupContext.test.ts +120 -0
  107. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/actions/PopupSubTableEditActionModel.tsx +10 -2
  108. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +24 -1
  109. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -136
  110. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +167 -3
  111. package/src/flow/models/fields/AssociationFieldModel/recordSelectShared.tsx +156 -0
  112. package/src/flow/models/fields/DisplayNumberFieldModel.tsx +1 -1
  113. package/src/flow/models/fields/NumberFieldModel.tsx +1 -1
  114. package/src/flow/models/fields/__tests__/DisplayNumberFieldModel.test.ts +33 -0
  115. package/src/flow/models/fields/__tests__/NumberFieldModel.test.tsx +47 -0
  116. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +6 -0
  117. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +27 -2
  118. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +139 -13
  119. package/src/index.ts +2 -0
  120. package/src/layout-manager/LayoutContentRoute.tsx +2 -1
  121. package/src/layout-manager/LayoutRoute.tsx +2 -1
  122. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +87 -1
  123. package/src/nocobase-buildin-plugin/index.tsx +14 -1
  124. package/src/settings-app/SettingsGroupNav.tsx +11 -11
  125. package/src/settings-app/SettingsSearch.tsx +115 -10
  126. package/src/settings-app/SettingsShell.tsx +23 -6
  127. package/src/settings-app/runtimePaths.ts +0 -1
  128. package/src/settings-app/settingsTheme.ts +82 -14
  129. package/src/settings-center/AdminSettingsLayout.tsx +132 -107
  130. package/src/settings-center/__tests__/groups.test.ts +25 -0
  131. package/src/settings-center/groups.ts +1 -1
  132. package/src/ui-operation/index.ts +33 -0
  133. package/src/ui-operation/ui-operation-codec.ts +54 -0
@@ -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';
@@ -34,6 +34,8 @@ const { DateFilterDynamicComponent: DateFilterDynamicComponentLazy } = lazy(
34
34
  'DateFilterDynamicComponent',
35
35
  );
36
36
 
37
+ const fallbackInputComponents = new Set(['Input', 'Input.URL', 'NanoIDInput', 'DatePicker']);
38
+
37
39
  // 简化的本地辅助:按需获取 ctx.collection 的字段树(MetaTreeNode[])
38
40
  async function buildCollectionLeftMetaTreeLocal(ctx: any): Promise<MetaTreeNode[]> {
39
41
  const resolve = async (sub: any): Promise<MetaTreeNode[]> => {
@@ -276,9 +278,9 @@ function createStaticInputRenderer(
276
278
  onChange={(v: unknown) => onChange?.(v as VariableFilterItemValue['value'])}
277
279
  />
278
280
  );
279
- // 未内置的 x-component:尝试从 app 解析组件
281
+ // 未直接处理的 x-component:尝试从 app 解析组件,可由筛选器接管的过渡组件查找失败时静默回退到 Input
280
282
  if (xComp && app?.getComponent) {
281
- const Comp = app.getComponent(xComp as any);
283
+ const Comp = app.getComponent(xComp, !fallbackInputComponents.has(xComp));
282
284
  if (Comp) {
283
285
  return (
284
286
  <Comp
@@ -595,8 +597,14 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
595
597
 
596
598
  const renderRightValueComponent = useCallback(() => {
597
599
  // 文本类多关键词:优先使用操作符 schema 声明的组件
598
- const resolved = resolveOperatorComponent(model.context.app, operator, operatorMetaList);
599
600
  const supportKeyword = operator === '$in' || operator === '$notIn';
601
+ const operatorComponent = currentOpMeta?.schema?.['x-component'] as string | undefined;
602
+ const isLocallyRenderedComponent =
603
+ operatorComponent === 'Select' ||
604
+ (typeof operatorComponent === 'string' && fallbackInputComponents.has(operatorComponent));
605
+ const resolved = supportKeyword
606
+ ? resolveOperatorComponent(model.context.app, operator, operatorMetaList, !isLocallyRenderedComponent)
607
+ : null;
600
608
  if (resolved && supportKeyword) {
601
609
  const { Comp, props: xProps } = resolved;
602
610
  const nextProps = { ...xProps };
@@ -642,6 +650,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
642
650
  disabled,
643
651
  operator,
644
652
  operatorMetaList,
653
+ currentOpMeta,
645
654
  rightValue,
646
655
  staticInputRenderer,
647
656
  model.context.app,