@nocobase/client-v2 2.1.11 → 2.2.0-alpha.2

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 (137) hide show
  1. package/es/PluginSettingsManager.d.ts +33 -0
  2. package/es/RouteRepository.d.ts +21 -6
  3. package/es/components/category-tabs/SortableCategoryTabs.d.ts +55 -0
  4. package/es/components/category-tabs/index.d.ts +9 -0
  5. package/es/components/form/TypedVariableInput.d.ts +22 -4
  6. package/es/components/form/filter/CollectionFilterItem.d.ts +11 -1
  7. package/es/components/form/filter/index.d.ts +2 -0
  8. package/es/components/index.d.ts +2 -0
  9. package/es/flow/actions/afterSuccess.d.ts +8 -1
  10. package/es/flow/actions/index.d.ts +1 -1
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  13. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  14. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  15. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  16. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  17. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  18. package/es/flow/index.d.ts +1 -0
  19. package/es/flow/models/base/GridModel.d.ts +1 -1
  20. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  21. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  22. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  23. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  24. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  25. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  26. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  27. package/es/index.d.ts +2 -1
  28. package/es/index.mjs +181 -131
  29. package/lib/index.js +186 -136
  30. package/package.json +7 -7
  31. package/src/PluginSettingsManager.ts +53 -0
  32. package/src/RouteRepository.ts +126 -24
  33. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  34. package/src/__tests__/RouteRepository.test.ts +216 -0
  35. package/src/__tests__/browserChecker.test.ts +61 -0
  36. package/src/__tests__/exports.test.ts +16 -0
  37. package/src/__tests__/plugin-manager.test.tsx +44 -2
  38. package/src/__tests__/settings-center.test.tsx +40 -1
  39. package/src/collection-manager/field-configure.ts +1 -1
  40. package/src/collection-manager/interfaces/id.ts +1 -1
  41. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  42. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  43. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  44. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  45. package/src/collection-manager/interfaces/obo.tsx +2 -2
  46. package/src/collection-manager/interfaces/oho.tsx +2 -2
  47. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  48. package/src/collection-manager/interfaces/uuid.ts +1 -1
  49. package/src/components/README.md +7 -1
  50. package/src/components/README.zh-CN.md +6 -1
  51. package/src/components/category-tabs/SortableCategoryTabs.tsx +210 -0
  52. package/src/components/category-tabs/index.ts +10 -0
  53. package/src/components/form/TypedVariableInput.tsx +416 -93
  54. package/src/components/form/__tests__/TypedVariableInput.test.tsx +266 -9
  55. package/src/components/form/filter/CollectionFilterItem.tsx +32 -7
  56. package/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +38 -0
  57. package/src/components/form/filter/index.ts +2 -0
  58. package/src/components/index.ts +2 -0
  59. package/src/flow/__tests__/FlowRoute.test.tsx +443 -5
  60. package/src/flow/__tests__/getKey.test.ts +7 -0
  61. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  62. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  63. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
  64. package/src/flow/actions/afterSuccess.tsx +142 -3
  65. package/src/flow/actions/customVariable.tsx +1 -2
  66. package/src/flow/actions/index.ts +1 -1
  67. package/src/flow/actions/linkageRules.tsx +2 -7
  68. package/src/flow/actions/openView.tsx +38 -4
  69. package/src/flow/actions/runjs.tsx +2 -14
  70. package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
  71. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
  72. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
  73. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +34 -10
  74. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +310 -0
  75. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +44 -8
  76. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  77. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  78. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  79. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  80. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  81. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  82. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  83. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
  84. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +166 -2
  85. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  86. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  87. package/src/flow/components/DefaultValue.tsx +1 -2
  88. package/src/flow/components/FieldAssignValueInput.tsx +10 -5
  89. package/src/flow/components/FlowRoute.tsx +56 -11
  90. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  91. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  92. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  93. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  94. package/src/flow/index.ts +1 -0
  95. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  96. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  97. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  98. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  99. package/src/flow/models/base/ActionModel.tsx +12 -1
  100. package/src/flow/models/base/GridModel.tsx +38 -7
  101. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +0 -4
  102. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  103. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  104. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  105. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +1 -2
  106. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  107. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  108. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  109. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  110. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  111. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  112. package/src/flow/models/blocks/form/value-runtime/rules.ts +2 -2
  113. package/src/flow/models/blocks/js-block/JSBlock.tsx +2 -7
  114. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  115. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +36 -13
  116. package/src/flow/models/blocks/table/TableBlockModel.tsx +38 -17
  117. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +111 -1
  118. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  119. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  120. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  121. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  122. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  123. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  124. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  125. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  126. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  127. package/src/flow/models/topbar/TopbarActionModel.tsx +93 -10
  128. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  129. package/src/flow-compat/Popover.tsx +43 -4
  130. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  131. package/src/index.ts +8 -1
  132. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  133. package/src/nocobase-buildin-plugin/index.tsx +0 -2
  134. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  135. package/src/settings-center/SystemSettingsPage.tsx +1 -2
  136. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  137. package/src/settings-center/utils.tsx +0 -6
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { FlowContext, FlowContextProvider } from '@nocobase/flow-engine';
10
+ import { FlowContext, FlowContextProvider, type MetaTreeNode } from '@nocobase/flow-engine';
11
11
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
12
12
  import React from 'react';
13
13
  import { describe, expect, it, vi } from 'vitest';
@@ -78,6 +78,68 @@ describe('TypedVariableInput - constant rendering', () => {
78
78
  expect(nullInput).toBeInTheDocument();
79
79
  expect(nullInput.getAttribute('readonly')).not.toBeNull();
80
80
  });
81
+
82
+ it('defaults undefined to the first constant type', async () => {
83
+ const ctx = createContextWithEnv();
84
+ const handleChange = vi.fn();
85
+ renderWithCtx(
86
+ ctx,
87
+ <TypedVariableInput
88
+ value={undefined}
89
+ types={['string', 'number']}
90
+ namespaces={['$env']}
91
+ nullable
92
+ onChange={handleChange}
93
+ />,
94
+ );
95
+
96
+ expect(screen.queryByPlaceholderText('<Null>')).toBeNull();
97
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
98
+ await waitFor(() => {
99
+ expect(handleChange).toHaveBeenCalledWith('');
100
+ });
101
+ });
102
+
103
+ it('can still opt out to keep the null placeholder for undefined', async () => {
104
+ const ctx = createContextWithEnv();
105
+ renderWithCtx(
106
+ ctx,
107
+ <TypedVariableInput
108
+ value={undefined}
109
+ types={['string', 'number']}
110
+ namespaces={['$env']}
111
+ nullable
112
+ defaultToFirstConstantTypeWhenUndefined={false}
113
+ onChange={() => undefined}
114
+ />,
115
+ );
116
+
117
+ const nullInput = await screen.findByPlaceholderText('<Null>');
118
+ expect(nullInput).toBeInTheDocument();
119
+ });
120
+
121
+ it('highlights the defaulted first constant type when the switcher opens', async () => {
122
+ const ctx = createContextWithEnv();
123
+ renderWithCtx(
124
+ ctx,
125
+ <TypedVariableInput
126
+ value={undefined}
127
+ types={['string', 'number']}
128
+ namespaces={['$env']}
129
+ nullable
130
+ onChange={() => undefined}
131
+ />,
132
+ );
133
+
134
+ fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
135
+
136
+ await waitFor(() => {
137
+ const constant = screen.getAllByText('Constant')[0];
138
+ const string = screen.getAllByText('String')[0];
139
+ expect(constant.closest('.ant-cascader-menu-item-active')).not.toBeNull();
140
+ expect(string.closest('.ant-cascader-menu-item-active')).not.toBeNull();
141
+ });
142
+ });
81
143
  });
82
144
 
83
145
  describe('TypedVariableInput - variable rendering', () => {
@@ -99,10 +161,10 @@ describe('TypedVariableInput - variable rendering', () => {
99
161
  });
100
162
  });
101
163
 
102
- it('clears back to null when the close button is clicked (nullable=true)', async () => {
164
+ it('clears back to default-of-first-type when the close button is clicked (nullable=true)', async () => {
103
165
  const ctx = createContextWithEnv();
104
166
  const handleChange = vi.fn();
105
- renderWithCtx(
167
+ const { container } = renderWithCtx(
106
168
  ctx,
107
169
  <TypedVariableInput
108
170
  value="{{$env.SMTP_PORT}}"
@@ -112,15 +174,17 @@ describe('TypedVariableInput - variable rendering', () => {
112
174
  onChange={handleChange}
113
175
  />,
114
176
  );
115
- const clear = await screen.findByRole('button', { name: 'icon-close' });
116
- fireEvent.click(clear);
117
- expect(handleChange).toHaveBeenCalledWith(null);
177
+ const clear = container.querySelector('button.clear-button') as HTMLButtonElement | null;
178
+ expect(clear).not.toBeNull();
179
+ expect(clear).toHaveClass('clear-button');
180
+ fireEvent.click(clear as HTMLButtonElement);
181
+ expect(handleChange).toHaveBeenCalledWith(0);
118
182
  });
119
183
 
120
184
  it('clears back to default-of-first-type when nullable=false', async () => {
121
185
  const ctx = createContextWithEnv();
122
186
  const handleChange = vi.fn();
123
- renderWithCtx(
187
+ const { container } = renderWithCtx(
124
188
  ctx,
125
189
  <TypedVariableInput
126
190
  value="{{$env.SMTP_PORT}}"
@@ -130,12 +194,205 @@ describe('TypedVariableInput - variable rendering', () => {
130
194
  onChange={handleChange}
131
195
  />,
132
196
  );
133
- const clear = await screen.findByRole('button', { name: 'icon-close' });
134
- fireEvent.click(clear);
197
+ const clear = container.querySelector('button.clear-button') as HTMLButtonElement | null;
198
+ expect(clear).not.toBeNull();
199
+ fireEvent.click(clear as HTMLButtonElement);
135
200
  expect(handleChange).toHaveBeenCalledWith(0);
136
201
  });
137
202
  });
138
203
 
204
+ describe('TypedVariableInput - object / JSON constant', () => {
205
+ it('renders a JSON textarea for an object value when types include object', async () => {
206
+ const ctx = createContextWithEnv();
207
+ renderWithCtx(
208
+ ctx,
209
+ <TypedVariableInput value={{ a: 1 }} types={['object']} namespaces={['$env']} onChange={() => undefined} />,
210
+ );
211
+ // The object is stringified into a textarea.
212
+ const textarea = await screen.findByDisplayValue(/"a": 1/);
213
+ expect(textarea.tagName.toLowerCase()).toBe('textarea');
214
+ });
215
+
216
+ it('parses valid JSON on blur and emits the parsed object', async () => {
217
+ const ctx = createContextWithEnv();
218
+ const handleChange = vi.fn();
219
+ renderWithCtx(
220
+ ctx,
221
+ <TypedVariableInput value={{}} types={['object']} namespaces={['$env']} onChange={handleChange} />,
222
+ );
223
+ const textarea = await screen.findByDisplayValue('{}');
224
+ fireEvent.change(textarea, { target: { value: '{"x": 42}' } });
225
+ fireEvent.blur(textarea);
226
+ expect(handleChange).toHaveBeenLastCalledWith({ x: 42 });
227
+ });
228
+
229
+ it('shows an error live on change (before blur) and does not emit on invalid JSON', async () => {
230
+ const ctx = createContextWithEnv();
231
+ const handleChange = vi.fn();
232
+ renderWithCtx(
233
+ ctx,
234
+ <TypedVariableInput value={{}} types={['object']} namespaces={['$env']} onChange={handleChange} />,
235
+ );
236
+ const textarea = await screen.findByDisplayValue('{}');
237
+ // Typing an invalid value surfaces the error immediately — no blur needed (mirrors v1's `Json`, which validates on
238
+ // every change).
239
+ fireEvent.change(textarea, { target: { value: '{ not json' } });
240
+ await waitFor(() => {
241
+ // The raw `JSON.parse` error message is shown (matching v1), e.g. "Expected property name or '}' in JSON at
242
+ // position …".
243
+ expect(screen.getByText(/Expected property name/i)).toBeInTheDocument();
244
+ });
245
+ // The value is only emitted on blur, and never for an invalid value.
246
+ fireEvent.blur(textarea);
247
+ expect(handleChange).not.toHaveBeenCalled();
248
+ });
249
+
250
+ it('defaults to {} when the Constant > JSON type is picked', async () => {
251
+ const ctx = createContextWithEnv();
252
+ const handleChange = vi.fn();
253
+ renderWithCtx(
254
+ ctx,
255
+ <TypedVariableInput value={null} types={['object']} namespaces={['$env']} onChange={handleChange} />,
256
+ );
257
+ fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
258
+ // Constant submenu → JSON leaf.
259
+ fireEvent.click(await screen.findByText('Constant'));
260
+ fireEvent.click(await screen.findByText('JSON'));
261
+ expect(handleChange).toHaveBeenCalledWith({});
262
+ });
263
+ });
264
+
265
+ describe('TypedVariableInput - injected metaTree', () => {
266
+ // A bare context with no global `$env` — the injected tree is the only source.
267
+ function createBareContext() {
268
+ const ctx = new FlowContext();
269
+ (ctx as any).t = (key: string) => key;
270
+ return ctx;
271
+ }
272
+
273
+ // Mirrors a workflow node-result tree: a root whose children load lazily.
274
+ function makeLazyTree(loadChildren: () => Promise<MetaTreeNode[]>): MetaTreeNode[] {
275
+ return [
276
+ {
277
+ name: '$jobsMapByNodeKey',
278
+ title: 'Node result',
279
+ type: 'object',
280
+ paths: ['$jobsMapByNodeKey'],
281
+ children: loadChildren,
282
+ },
283
+ ];
284
+ }
285
+
286
+ it('renders the injected tree in the switcher, not the global one', async () => {
287
+ const ctx = createBareContext();
288
+ const metaTree: MetaTreeNode[] = [
289
+ { name: 'n1', title: 'Approval node', type: 'object', paths: ['$jobsMapByNodeKey', 'n1'] },
290
+ ];
291
+ renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={() => undefined} />);
292
+ fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
293
+ await waitFor(() => {
294
+ expect(screen.getByText('Approval node')).toBeInTheDocument();
295
+ });
296
+ });
297
+
298
+ it('lazily resolves function children when a node is expanded', async () => {
299
+ const ctx = createBareContext();
300
+ const loadChildren = vi.fn(async () => [
301
+ { name: 'status', title: 'Status', type: 'string', paths: ['$jobsMapByNodeKey', 'n1', 'status'] },
302
+ ]);
303
+ const metaTree = makeLazyTree(loadChildren);
304
+ renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={() => undefined} />);
305
+
306
+ fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
307
+ const root = await screen.findByText('Node result');
308
+ // Expanding the lazy node triggers loadData → loadMetaTreeChildren.
309
+ fireEvent.click(root);
310
+ await waitFor(() => {
311
+ expect(loadChildren).toHaveBeenCalled();
312
+ expect(screen.getByText('Status')).toBeInTheDocument();
313
+ });
314
+ });
315
+
316
+ it('surfaces lazy children on the FIRST expansion (no stuck-loading regression)', async () => {
317
+ const ctx = createBareContext();
318
+ // A deferred promise so loading→resolved is a real two-phase transition, reproducing the rc-cascader
319
+ // reference-cache bug where the first expand stayed stuck on the spinner until the column was reopened.
320
+ let resolveChildren: (nodes: MetaTreeNode[]) => void = () => undefined;
321
+ const childrenPromise = new Promise<MetaTreeNode[]>((resolve) => {
322
+ resolveChildren = resolve;
323
+ });
324
+ const loadChildren = vi.fn(() => childrenPromise);
325
+ const metaTree = makeLazyTree(loadChildren);
326
+ renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={() => undefined} />);
327
+
328
+ fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
329
+ const root = await screen.findByText('Node result');
330
+ fireEvent.click(root);
331
+ expect(loadChildren).toHaveBeenCalledTimes(1);
332
+ // Resolve after the click — the child must appear without reopening.
333
+ resolveChildren([
334
+ { name: 'secret', title: 'SMTP_HOST', type: 'string', paths: ['$jobsMapByNodeKey', 'n1', 'secret'] },
335
+ ]);
336
+ await waitFor(() => {
337
+ expect(screen.getByText('SMTP_HOST')).toBeInTheDocument();
338
+ });
339
+ });
340
+
341
+ it('emits the selected variable as a {{ ... }} expression with no inner spaces', async () => {
342
+ const ctx = createBareContext();
343
+ const handleChange = vi.fn();
344
+ const metaTree: MetaTreeNode[] = [
345
+ { name: 'n1', title: 'Approval node', type: 'string', paths: ['$jobsMapByNodeKey', 'n1'] },
346
+ ];
347
+ renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={handleChange} />);
348
+ fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
349
+ const leaf = await screen.findByText('Approval node');
350
+ fireEvent.click(leaf);
351
+ await waitFor(() => {
352
+ expect(handleChange).toHaveBeenCalledWith('{{$jobsMapByNodeKey.n1}}');
353
+ });
354
+ });
355
+
356
+ it('preloads a saved variable label across a lazy level (deep label not dropped on mount)', async () => {
357
+ const ctx = createBareContext();
358
+ // The query-node case: a saved reference `$jobsMapByNodeKey.n1.role` points below `n1`, whose children are a lazy
359
+ // thunk (relation not yet expanded). On mount the tag must show the full path "Query / Role", not stop at "Query" —
360
+ // the component walks the saved path resolving each lazy level. (Regression: reopening a saved condition showed
361
+ // only "节点数据 / 查询数据" and dropped "角色标识".)
362
+ const loadRoleFields = vi.fn(async () => [
363
+ { name: 'role', title: 'Role', type: 'string', paths: ['$jobsMapByNodeKey', 'n1', 'role'] },
364
+ ]);
365
+ const metaTree: MetaTreeNode[] = [
366
+ {
367
+ name: '$jobsMapByNodeKey',
368
+ title: 'Node result',
369
+ type: 'object',
370
+ paths: ['$jobsMapByNodeKey'],
371
+ children: [
372
+ {
373
+ name: 'n1',
374
+ title: 'Query',
375
+ type: 'object',
376
+ paths: ['$jobsMapByNodeKey', 'n1'],
377
+ children: loadRoleFields,
378
+ },
379
+ ],
380
+ },
381
+ ];
382
+ renderWithCtx(
383
+ ctx,
384
+ <TypedVariableInput value="{{$jobsMapByNodeKey.n1.role}}" metaTree={metaTree} onChange={() => undefined} />,
385
+ );
386
+ const tag = screen.getByRole('button', { name: 'variable-tag' });
387
+ // Preload resolves the lazy level so the deep label appears without expanding.
388
+ await waitFor(() => {
389
+ expect(loadRoleFields).toHaveBeenCalled();
390
+ expect(tag.textContent).toContain('Role');
391
+ });
392
+ expect(tag.textContent).toContain('Query');
393
+ });
394
+ });
395
+
139
396
  describe('TypedVariableInput - editor onChange propagation', () => {
140
397
  it('forwards numeric edits via onChange', async () => {
141
398
  const ctx = createContextWithEnv();
@@ -58,6 +58,16 @@ export interface CollectionFilterItemProps {
58
58
  t?: (key: string) => string;
59
59
  /** Optional v2 app registry used to resolve plugin-provided operator components. */
60
60
  app?: { getComponent?: (name: string) => React.ComponentType<any> | undefined };
61
+ /** Optional override for the left field picker placeholder. */
62
+ fieldPlaceholder?: string;
63
+ /** Optional override for the operator picker placeholder. */
64
+ operatorPlaceholder?: string;
65
+ /** Optional override for the value input placeholder. Pass `null` to suppress it. */
66
+ valuePlaceholder?: string | null;
67
+ /** Optional override for the left field picker width. Defaults to v2's original 200px. */
68
+ fieldWidth?: number;
69
+ /** Optional override for the operator picker min-width. Defaults to v2's original 120px. */
70
+ operatorMinWidth?: number;
61
71
  }
62
72
 
63
73
  const identity = (s: string) => s;
@@ -98,6 +108,12 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
98
108
  const { path: leftValue, operator, value: rightValue } = props.value;
99
109
  const flowEngine = useFlowEngine({ throwError: false }) as any;
100
110
  const app = props.app || flowEngine?.context?.app;
111
+ const fieldPlaceholder = props.fieldPlaceholder ?? t('Select field');
112
+ const operatorPlaceholder = props.operatorPlaceholder ?? t('Comparision');
113
+ const valuePlaceholder =
114
+ props.valuePlaceholder === undefined ? t('Enter value') : props.valuePlaceholder || undefined;
115
+ const fieldWidth = props.fieldWidth ?? 200;
116
+ const operatorMinWidth = props.operatorMinWidth ?? 120;
101
117
 
102
118
  const options = useFilterOptions(collection, { filterableFieldNames, nonfilterableFieldNames, noIgnore, t });
103
119
 
@@ -135,8 +151,8 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
135
151
  return (
136
152
  <Space wrap>
137
153
  <Cascader
138
- style={{ width: 200 }}
139
- placeholder={t('Select field')}
154
+ style={{ width: fieldWidth }}
155
+ placeholder={fieldPlaceholder}
140
156
  options={cascaderOptions}
141
157
  value={fieldPath}
142
158
  onChange={handleFieldChange}
@@ -145,15 +161,15 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
145
161
  popupClassName={cascaderPopupClass}
146
162
  />
147
163
  <Select
148
- style={{ width: 120 }}
149
- placeholder={t('Comparision')}
164
+ style={{ minWidth: operatorMinWidth }}
165
+ placeholder={operatorPlaceholder}
150
166
  value={operator || undefined}
151
167
  onChange={handleOperatorChange}
152
168
  disabled={!leftValue || operatorOptions.length === 0}
153
169
  >
154
170
  {operatorOptions.map((op) => (
155
171
  <Select.Option key={op.value} value={op.value}>
156
- {op.label}
172
+ {typeof op.label === 'string' ? t(op.label) : op.label}
157
173
  </Select.Option>
158
174
  ))}
159
175
  </Select>
@@ -162,7 +178,7 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
162
178
  operator={selectedOperator}
163
179
  value={rightValue}
164
180
  onChange={handleValueChange}
165
- placeholder={t('Enter value')}
181
+ placeholder={valuePlaceholder}
166
182
  t={t}
167
183
  app={app}
168
184
  />
@@ -179,7 +195,16 @@ export function createCollectionFilterItem(
179
195
  collection: Collection,
180
196
  bound?: Pick<
181
197
  CollectionFilterItemProps,
182
- 'filterableFieldNames' | 'nonfilterableFieldNames' | 'noIgnore' | 't' | 'app'
198
+ | 'filterableFieldNames'
199
+ | 'nonfilterableFieldNames'
200
+ | 'noIgnore'
201
+ | 't'
202
+ | 'app'
203
+ | 'fieldPlaceholder'
204
+ | 'operatorPlaceholder'
205
+ | 'valuePlaceholder'
206
+ | 'fieldWidth'
207
+ | 'operatorMinWidth'
183
208
  >,
184
209
  ) {
185
210
  const Component: FC<{ value: CollectionFilterItemValue }> = (props) => (
@@ -244,4 +244,42 @@ describe('CollectionFilterItem', () => {
244
244
  expect(value.path).toBe('username');
245
245
  });
246
246
  });
247
+
248
+ it('keeps default widths at 200/120 and honours explicit width/placeholder overrides', () => {
249
+ const value = observable({ path: 'username', operator: '$eq', value: '' });
250
+ const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
251
+
252
+ const { container, rerender } = render(<CollectionFilterItem value={value} collection={collection} />);
253
+
254
+ let selects = container.querySelectorAll('.ant-select');
255
+ expect(selects[0]).toHaveStyle({ width: '200px' });
256
+ expect(selects[1]).toHaveStyle({ minWidth: '120px' });
257
+
258
+ rerender(
259
+ <CollectionFilterItem
260
+ value={observable({ path: '', operator: '', value: '' })}
261
+ collection={collection}
262
+ fieldWidth={160}
263
+ operatorMinWidth={110}
264
+ fieldPlaceholder="Choose field"
265
+ operatorPlaceholder="Choose operator"
266
+ />,
267
+ );
268
+
269
+ selects = container.querySelectorAll('.ant-select');
270
+ expect(selects[0]).toHaveStyle({ width: '160px' });
271
+ expect(selects[1]).toHaveStyle({ minWidth: '110px' });
272
+ expect(screen.getByText('Choose field')).toBeInTheDocument();
273
+ });
274
+
275
+ it('lets callers suppress the fallback value placeholder', () => {
276
+ const value = observable({ path: 'username', operator: '$eq', value: '' });
277
+ const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
278
+
279
+ const { rerender } = render(<CollectionFilterItem value={value} collection={collection} />);
280
+ expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument();
281
+
282
+ rerender(<CollectionFilterItem value={value} collection={collection} valuePlaceholder={null} />);
283
+ expect(screen.queryByPlaceholderText('Enter value')).not.toBeInTheDocument();
284
+ });
247
285
  });
@@ -11,5 +11,7 @@
11
11
  export { CollectionFilter } from './CollectionFilter';
12
12
  export type { CollectionFilterProps } from './CollectionFilter';
13
13
  export { CollectionFilterPanel } from './CollectionFilterPanel';
14
+ export { CollectionFilterItem } from './CollectionFilterItem';
15
+ export type { CollectionFilterItemValue } from './CollectionFilterItem';
14
16
  export type { CollectionFilterPanelProps, CollectionFilterPanelRef } from './CollectionFilterPanel';
15
17
  export type { CompiledFilter } from './useFilterActionProps';
@@ -9,9 +9,11 @@
9
9
 
10
10
  export * from './AppComponents';
11
11
  export * from './BlankComponent';
12
+ export * from './category-tabs';
12
13
  export * from './form/table/dnd';
13
14
  export * from './form';
14
15
  export * from './Icon';
16
+ export * from './KeepAlive';
15
17
  export * from './PoweredBy';
16
18
  export * from './RouterContextCleaner';
17
19
  export * from './SwitchLanguage';