@nocobase/client-v2 2.1.0-beta.30 → 2.1.0-beta.33

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 (101) hide show
  1. package/es/BaseApplication.d.ts +1 -0
  2. package/es/PluginManager.d.ts +1 -0
  3. package/es/components/form/DrawerFormLayout.d.ts +49 -0
  4. package/es/components/form/EnvVariableInput.d.ts +42 -0
  5. package/es/components/form/FileSizeInput.d.ts +27 -0
  6. package/es/components/form/createFormRegistry.d.ts +33 -0
  7. package/es/components/form/index.d.ts +13 -0
  8. package/es/components/index.d.ts +1 -1
  9. package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
  10. package/es/flow/components/code-editor/index.d.ts +1 -0
  11. package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
  12. package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
  13. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
  14. package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
  15. package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
  16. package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
  17. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +4 -0
  18. package/es/flow/models/fields/ClickableFieldModel.d.ts +3 -0
  19. package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
  20. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +2 -2
  21. package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
  22. package/es/flow-compat/passwordUtils.d.ts +1 -1
  23. package/es/index.mjs +119 -108
  24. package/es/utils/remotePlugins.d.ts +0 -4
  25. package/lib/index.js +122 -111
  26. package/package.json +9 -5
  27. package/src/BaseApplication.tsx +14 -8
  28. package/src/PluginManager.ts +1 -0
  29. package/src/__tests__/app.test.tsx +28 -1
  30. package/src/__tests__/globalDeps.test.ts +1 -0
  31. package/src/__tests__/remotePlugins.test.ts +29 -18
  32. package/src/components/form/DrawerFormLayout.tsx +103 -0
  33. package/src/components/form/EnvVariableInput.tsx +126 -0
  34. package/src/components/form/FileSizeInput.tsx +105 -0
  35. package/src/components/form/createFormRegistry.ts +60 -0
  36. package/src/components/form/index.tsx +14 -0
  37. package/src/components/index.ts +1 -1
  38. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
  39. package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
  40. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
  41. package/src/flow/actions/__tests__/pattern.test.ts +134 -0
  42. package/src/flow/actions/__tests__/titleField.test.ts +45 -0
  43. package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
  44. package/src/flow/actions/formAssignRules.tsx +24 -9
  45. package/src/flow/actions/linkageRules.tsx +240 -258
  46. package/src/flow/actions/pattern.tsx +41 -6
  47. package/src/flow/actions/setTargetDataScope.tsx +32 -3
  48. package/src/flow/actions/titleField.tsx +4 -2
  49. package/src/flow/actions/validation.tsx +1 -1
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
  51. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
  52. package/src/flow/components/DynamicFlowsIcon.tsx +87 -13
  53. package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
  54. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +195 -8
  55. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  56. package/src/flow/components/code-editor/index.tsx +12 -8
  57. package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
  58. package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
  59. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
  60. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
  61. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
  62. package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
  63. package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
  64. package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
  65. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
  66. package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
  67. package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
  68. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
  69. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
  70. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
  71. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
  72. package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  73. package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
  74. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
  75. package/src/flow/models/blocks/form/FormActionModel.tsx +2 -8
  76. package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
  77. package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
  78. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
  79. package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
  80. package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
  81. package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
  82. package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
  83. package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
  84. package/src/flow/models/blocks/table/TableColumnModel.tsx +8 -2
  85. package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
  86. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
  87. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +132 -1
  88. package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +144 -3
  90. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +170 -1
  91. package/src/flow/models/fields/ClickableFieldModel.tsx +55 -6
  92. package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
  93. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +40 -7
  94. package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
  95. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +202 -1
  96. package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
  97. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
  98. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
  99. package/src/flow/models/utils/displayValueUtils.ts +57 -0
  100. package/src/utils/globalDeps.ts +11 -0
  101. package/src/utils/remotePlugins.ts +7 -27
@@ -15,7 +15,7 @@ import type { MetaTreeNode } from '@nocobase/flow-engine';
15
15
  import { FieldAssignRulesEditor, type FieldAssignRuleItem } from '../FieldAssignRulesEditor';
16
16
  import { mergeItemMetaTreeForAssignValue } from '../FieldAssignValueInput';
17
17
 
18
- const { mockFieldAssignValueInput } = vi.hoisted(() => ({
18
+ const { mockFieldAssignValueInput, mockConditionBuilder } = vi.hoisted(() => ({
19
19
  mockFieldAssignValueInput: vi.fn((props: any) => (
20
20
  <div
21
21
  data-testid="mock-value-input"
@@ -25,6 +25,9 @@ const { mockFieldAssignValueInput } = vi.hoisted(() => ({
25
25
  data-date-constant={props?.enableDateVariableAsConstant ? 'yes' : 'no'}
26
26
  />
27
27
  )),
28
+ mockConditionBuilder: vi.fn((props: any) => (
29
+ <div data-testid="mock-condition-builder" data-extra={props?.extraMetaTree ? 'yes' : 'no'} />
30
+ )),
28
31
  }));
29
32
 
30
33
  vi.mock('../FieldAssignValueInput', async () => {
@@ -39,9 +42,7 @@ vi.mock('../ConditionBuilder', async () => {
39
42
  const actual = await vi.importActual<typeof import('../ConditionBuilder')>('../ConditionBuilder');
40
43
  return {
41
44
  ...actual,
42
- ConditionBuilder: (props: any) => (
43
- <div data-testid="mock-condition-builder" data-extra={props?.extraMetaTree ? 'yes' : 'no'} />
44
- ),
45
+ ConditionBuilder: mockConditionBuilder,
45
46
  };
46
47
  });
47
48
 
@@ -55,6 +56,7 @@ describe('FieldAssignRulesEditor', () => {
55
56
 
56
57
  beforeEach(() => {
57
58
  mockFieldAssignValueInput.mockClear();
59
+ mockConditionBuilder.mockClear();
58
60
  });
59
61
 
60
62
  const createAssociationFixture = () => {
@@ -574,6 +576,81 @@ describe('FieldAssignRulesEditor', () => {
574
576
  expect(getByTestId('mock-condition-builder').getAttribute('data-extra')).toBe('yes');
575
577
  });
576
578
 
579
+ it('preserves uiSchema enum for current item attributes in condition extra tree', async () => {
580
+ const aaaField: any = {
581
+ name: 'AAA',
582
+ title: 'AAA',
583
+ type: 'array',
584
+ interface: 'multipleSelect',
585
+ isAssociationField: () => false,
586
+ uiSchema: {
587
+ enum: [
588
+ { label: 'Test1', value: 'Test1' },
589
+ { label: 'Test2', value: 'Test2' },
590
+ ],
591
+ },
592
+ };
593
+ const roleNameField: any = {
594
+ name: 'name',
595
+ title: 'Role name',
596
+ type: 'string',
597
+ interface: 'input',
598
+ isAssociationField: () => false,
599
+ };
600
+ const rolesCollection = {
601
+ getField: (name: string) => (name === 'AAA' ? aaaField : name === 'name' ? roleNameField : null),
602
+ getFields: () => [roleNameField, aaaField],
603
+ };
604
+ const rolesField: any = {
605
+ name: 'roles',
606
+ title: 'Roles',
607
+ type: 'belongsToMany',
608
+ interface: 'm2m',
609
+ isAssociationField: () => true,
610
+ targetCollection: rolesCollection,
611
+ };
612
+ const rootCollection = {
613
+ getField: (name: string) => (name === 'roles' ? rolesField : null),
614
+ getFields: () => [rolesField],
615
+ };
616
+ const value: FieldAssignRuleItem[] = [
617
+ {
618
+ key: 'rule-roles-name',
619
+ enable: true,
620
+ targetPath: 'roles.name',
621
+ mode: 'assign',
622
+ condition: { logic: '$and', items: [] },
623
+ },
624
+ ];
625
+
626
+ render(
627
+ wrap(
628
+ <FieldAssignRulesEditor t={t} fieldOptions={[]} rootCollection={rootCollection} value={value} showCondition />,
629
+ ),
630
+ );
631
+
632
+ const conditionProps = mockConditionBuilder.mock.calls[mockConditionBuilder.mock.calls.length - 1]?.[0];
633
+ const extraMetaTree = conditionProps?.extraMetaTree as MetaTreeNode[] | undefined;
634
+ expect(Array.isArray(extraMetaTree)).toBe(true);
635
+
636
+ const itemNode = extraMetaTree?.find((node) => node.name === 'item') as MetaTreeNode | undefined;
637
+ expect(itemNode).toBeTruthy();
638
+
639
+ const itemChildren = (Array.isArray(itemNode?.children) ? itemNode.children : []) as MetaTreeNode[];
640
+ const attributesNode = itemChildren.find((node) => node.name === 'value') as MetaTreeNode | undefined;
641
+ expect(attributesNode).toBeTruthy();
642
+
643
+ const attributeChildren =
644
+ typeof attributesNode?.children === 'function' ? await attributesNode.children() : attributesNode?.children ?? [];
645
+ const aaaNode = (attributeChildren as MetaTreeNode[]).find((node) => node.name === 'AAA') as any;
646
+
647
+ expect(aaaNode?.interface).toBe('multipleSelect');
648
+ expect(aaaNode?.uiSchema?.enum).toEqual([
649
+ { label: 'Test1', value: 'Test1' },
650
+ { label: 'Test2', value: 'Test2' },
651
+ ]);
652
+ });
653
+
577
654
  it('renders empty state when no items', () => {
578
655
  const { container } = render(
579
656
  wrap(<FieldAssignRulesEditor t={t} fieldOptions={[]} value={[]} showCondition={false} />),
@@ -42,6 +42,7 @@ interface CodeEditorProps {
42
42
  language?: string;
43
43
  scene?: string | string[];
44
44
  RightExtra?: React.FC<any>;
45
+ showLogs?: boolean;
45
46
  }
46
47
 
47
48
  export * from './types';
@@ -64,6 +65,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
64
65
  language,
65
66
  scene,
66
67
  RightExtra,
68
+ showLogs = true,
67
69
  }) => {
68
70
  const wrapperRef = useRef<HTMLDivElement>(null);
69
71
  const viewRef = useRef<EditorView | null>(null);
@@ -249,14 +251,16 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
249
251
  completionSource={completionSource}
250
252
  viewRef={viewRef}
251
253
  />
252
- <LogsPanel
253
- logs={logs}
254
- onJumpTo={(line, column) => {
255
- const view = viewRef.current;
256
- if (view) jumpTo(view, line, column);
257
- }}
258
- tr={tr}
259
- />
254
+ {showLogs ? (
255
+ <LogsPanel
256
+ logs={logs}
257
+ onJumpTo={(line, column) => {
258
+ const view = viewRef.current;
259
+ if (view) jumpTo(view, line, column);
260
+ }}
261
+ tr={tr}
262
+ />
263
+ ) : null}
260
264
  <SnippetsDrawer
261
265
  open={snippetOpen}
262
266
  onClose={() => setSnippetOpen(false)}
@@ -22,7 +22,12 @@ import {
22
22
  observer,
23
23
  } from '@nocobase/flow-engine';
24
24
  import { NumberPicker } from '@formily/antd-v5';
25
- import { enumToOptions, translateOptions, UiSchemaEnumItem } from '../../internal/utils/enumOptionsUtils';
25
+ import {
26
+ enumToOptions,
27
+ normalizeSelectRenderValue,
28
+ translateOptions,
29
+ UiSchemaEnumItem,
30
+ } from '../../internal/utils/enumOptionsUtils';
26
31
  import { mergeItemMetaTreeForAssignValue } from '../FieldAssignValueInput';
27
32
  import { resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
28
33
 
@@ -82,7 +87,9 @@ function createStaticInputRenderer(
82
87
  } else if (optionsFromEnum) {
83
88
  finalProps = { ...finalProps, options: optionsFromEnum };
84
89
  }
85
- return <Select {...finalProps} {...rest} value={value} onChange={onChange} />;
90
+ return (
91
+ <Select {...finalProps} {...rest} value={normalizeSelectRenderValue(value, finalProps)} onChange={onChange} />
92
+ );
86
93
  }
87
94
  if (xComponent === 'DateFilterDynamicComponent')
88
95
  return <DateFilterDynamicComponentLazy {...commonProps} {...rest} value={value} onChange={onChange} />;
@@ -25,7 +25,7 @@ import {
25
25
  } from '@nocobase/flow-engine';
26
26
  import _ from 'lodash';
27
27
  import { NumberPicker } from '@formily/antd-v5';
28
- import { enumToOptions, UiSchemaEnumItem } from '../../internal/utils/enumOptionsUtils';
28
+ import { enumToOptions, normalizeSelectRenderValue, UiSchemaEnumItem } from '../../internal/utils/enumOptionsUtils';
29
29
  import { resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
30
30
 
31
31
  const { DateFilterDynamicComponent: DateFilterDynamicComponentLazy } = lazy(
@@ -155,11 +155,7 @@ function createStaticInputRenderer(
155
155
  options={selectOptions}
156
156
  {...commonProps}
157
157
  {...rest}
158
- value={
159
- Array.isArray(value) || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
160
- ? (value as unknown)
161
- : undefined
162
- }
158
+ value={normalizeSelectRenderValue(value, commonProps) as any}
163
159
  onChange={(v) => onChange?.(v as unknown as VariableFilterItemValue['value'])}
164
160
  />
165
161
  );
@@ -67,6 +67,9 @@ function createModel() {
67
67
  return { model, app };
68
68
  }
69
69
 
70
+ const getRenderedSelectTexts = (container: HTMLElement) =>
71
+ Array.from(container.querySelectorAll('.ant-select-selection-item')).map((node) => (node.textContent || '').trim());
72
+
70
73
  describe('LinkageFilterItem', () => {
71
74
  beforeEach(() => {
72
75
  document.body.innerHTML = '';
@@ -201,6 +204,74 @@ describe('LinkageFilterItem', () => {
201
204
  });
202
205
  });
203
206
 
207
+ it('does not render an empty selected option for constant single select', async () => {
208
+ const value = observable({ path: '', operator: '', value: '' }) as any;
209
+ const { model } = createModel();
210
+
211
+ (globalThis as any).__TEST_META__ = {
212
+ interface: 'select',
213
+ uiSchema: {
214
+ 'x-component': 'Select',
215
+ enum: [{ label: 'Published', value: 'published' }],
216
+ 'x-filter-operators': [
217
+ {
218
+ value: '$eq',
219
+ label: 'is',
220
+ selected: true,
221
+ schema: { 'x-component': 'Select' },
222
+ },
223
+ ],
224
+ },
225
+ paths: ['collection', 'status'],
226
+ name: 'status',
227
+ title: 'Status',
228
+ type: 'string',
229
+ };
230
+
231
+ const view = render(<LinkageFilterItem value={value} model={model} />);
232
+ fireEvent.click(screen.getByTestId('variable-input'));
233
+
234
+ await waitFor(() => {
235
+ expect(value.operator).toBe('$eq');
236
+ });
237
+
238
+ expect(getRenderedSelectTexts(view.container).filter((text) => text === '')).toHaveLength(0);
239
+ });
240
+
241
+ it('does not render an empty selected tag for constant multi select', async () => {
242
+ const value = observable({ path: '', operator: '', value: '' }) as any;
243
+ const { model } = createModel();
244
+
245
+ (globalThis as any).__TEST_META__ = {
246
+ interface: 'select',
247
+ uiSchema: {
248
+ 'x-component': 'Select',
249
+ enum: [{ label: 'Published', value: 'published' }],
250
+ 'x-filter-operators': [
251
+ {
252
+ value: '$in',
253
+ label: 'is any of',
254
+ selected: true,
255
+ schema: { 'x-component': 'Select', 'x-component-props': { mode: 'tags' } },
256
+ },
257
+ ],
258
+ },
259
+ paths: ['collection', 'status'],
260
+ name: 'status',
261
+ title: 'Status',
262
+ type: 'string',
263
+ };
264
+
265
+ const view = render(<LinkageFilterItem value={value} model={model} />);
266
+ fireEvent.click(screen.getByTestId('variable-input'));
267
+
268
+ await waitFor(() => {
269
+ expect(value.operator).toBe('$in');
270
+ });
271
+
272
+ expect(getRenderedSelectTexts(view.container).filter((text) => text === '')).toHaveLength(0);
273
+ });
274
+
204
275
  it('passes enum options to single select multi-value operators', async () => {
205
276
  const value = observable({ path: '', operator: '', value: ['draft'] }) as any;
206
277
  const { model, app } = createModel();
@@ -47,6 +47,9 @@ vi.mock('@nocobase/flow-engine', async () => {
47
47
  return { ...actual, VariableInput: MockVariableInput };
48
48
  });
49
49
 
50
+ const getRenderedSelectTexts = (root: ParentNode = document.body) =>
51
+ Array.from(root.querySelectorAll('.ant-select-selection-item')).map((node) => (node.textContent || '').trim());
52
+
50
53
  function CreateModel() {
51
54
  const engine = new FlowEngine();
52
55
  const model = new FlowModel({ uid: 'm-variable-filter', flowEngine: engine });
@@ -262,6 +265,51 @@ describe('VariableFilterItem', () => {
262
265
  delete (globalThis as any).__TEST_META__;
263
266
  });
264
267
 
268
+ it('does not render an empty selected option for right constant select', async () => {
269
+ const value = observable({ path: '', operator: '', value: '' }) as any;
270
+ const model = CreateModel();
271
+
272
+ const prevMeta = (globalThis as any).__TEST_META__;
273
+ const prevPath = (globalThis as any).__TEST_PATH__;
274
+ (globalThis as any).__TEST_PATH__ = 'status';
275
+ (globalThis as any).__TEST_META__ = {
276
+ interface: 'select',
277
+ uiSchema: {
278
+ 'x-component': 'Select',
279
+ enum: [{ label: 'Draft', value: 'draft' }],
280
+ 'x-filter-operators': [
281
+ {
282
+ value: '$eq',
283
+ label: 'Equals',
284
+ selected: true,
285
+ schema: { 'x-component': 'Select' },
286
+ },
287
+ ],
288
+ },
289
+ paths: ['collection', 'status'],
290
+ name: 'status',
291
+ title: 'Status',
292
+ type: 'string',
293
+ };
294
+
295
+ render(<VariableFilterItem value={value} model={model} rightAsVariable />);
296
+ fireEvent.click(screen.getAllByTestId('variable-input')[0]);
297
+
298
+ await waitFor(() => {
299
+ expect(value.operator).toBe('$eq');
300
+ });
301
+
302
+ const rightVariableInputProps = (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
303
+ const Renderer = rightVariableInputProps?.converters?.renderInputComponent?.({ paths: ['constant'] });
304
+ expect(Renderer).toBeTruthy();
305
+
306
+ const rendered = render(<Renderer value="" onChange={vi.fn()} />);
307
+ expect(getRenderedSelectTexts(rendered.container).filter((text) => text === '')).toHaveLength(0);
308
+
309
+ (globalThis as any).__TEST_META__ = prevMeta;
310
+ (globalThis as any).__TEST_PATH__ = prevPath;
311
+ });
312
+
265
313
  it('renders right VariableInput when rightAsVariable=true and hides it for noValue operator', async () => {
266
314
  const value = observable({ path: '', operator: '', value: '' }) as any;
267
315
  const model = CreateModel();
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, it, expect } from 'vitest';
11
- import { enumToOptions, translateOptions } from '../enumOptionsUtils';
11
+ import { enumToOptions, getSelectedEnumLabels, translateOptions } from '../enumOptionsUtils';
12
12
 
13
13
  // 一个极简的 t:
14
14
  // - 直接返回传入 key;
@@ -55,4 +55,13 @@ describe('enumOptions utils', () => {
55
55
  { label: '否', value: false },
56
56
  ]);
57
57
  });
58
+
59
+ it('getSelectedEnumLabels: keeps label for selected value missing from limited options', () => {
60
+ const labels = getSelectedEnumLabels('published', [
61
+ { label: 'Draft', value: 'draft' },
62
+ { label: 'Published', value: 'published' },
63
+ ]);
64
+
65
+ expect(labels).toEqual([{ label: 'Published', value: 'published' }]);
66
+ });
58
67
  });
@@ -46,6 +46,35 @@ export function enumToOptions(uiEnum: UiSchemaEnumItem[] | undefined, t: (s: str
46
46
  return translateOptions(normalized, t);
47
47
  }
48
48
 
49
+ export function normalizeSelectRenderValue(value: unknown, props?: Record<string, any>) {
50
+ const mode = props?.mode;
51
+ const isMultiple = mode === 'multiple' || mode === 'tags' || props?.multiple === true;
52
+
53
+ if (Array.isArray(value)) {
54
+ return value.filter((item) => item !== '' && item !== null && typeof item !== 'undefined');
55
+ }
56
+
57
+ if (value === '' || value === null || typeof value === 'undefined') {
58
+ return isMultiple ? [] : undefined;
59
+ }
60
+
61
+ return value;
62
+ }
63
+
64
+ export function getSelectedEnumLabels(value: any, fallbackOptions: Option[] = []): Array<{ value: any; label: any }> {
65
+ const values = Array.isArray(value) ? value : value == null ? [] : [value];
66
+ return values.map((item) => {
67
+ const fallback = fallbackOptions.find((option) => option?.value === item);
68
+ if (fallback) {
69
+ return { value: item, label: fallback.label };
70
+ }
71
+ return {
72
+ value: item,
73
+ label: item?.toString?.() ?? item,
74
+ };
75
+ });
76
+ }
77
+
49
78
  // Populate model.props.options from uiSchema.enum if missing; returns whether options were injected
50
79
  export function ensureOptionsFromUiSchemaEnumIfAbsent(model: FlowModel, collectionField: CollectionField): boolean {
51
80
  const iface = collectionField?.interface;
@@ -16,6 +16,7 @@ import { SkeletonFallback } from '../../components/SkeletonFallback';
16
16
  import { ActionModel, ActionSceneEnum } from '../base';
17
17
  import {
18
18
  applyAssociateAction,
19
+ getAssociationSelectorContextInputArgs,
19
20
  getAssociationTargetResourceSettings,
20
21
  isAssociationBlockContext,
21
22
  } from './AssociationActionUtils';
@@ -139,7 +140,6 @@ AssociateActionModel.registerFlow({
139
140
  steps: {
140
141
  openSelector: {
141
142
  async handler(ctx, params) {
142
- const blockModel = ctx.blockModel;
143
143
  const targetResourceSettings = getAssociationTargetResourceSettings(ctx);
144
144
  const openMode = ctx.inputArgs?.isMobileLayout ? 'embed' : ctx.inputArgs?.mode || params?.mode || 'drawer';
145
145
  const size = ctx.inputArgs?.size || params?.size || 'medium';
@@ -168,9 +168,9 @@ AssociateActionModel.registerFlow({
168
168
  scene: 'select',
169
169
  dataSourceKey: targetResourceSettings.dataSourceKey,
170
170
  collectionName: targetResourceSettings.collectionName,
171
+ ...getAssociationSelectorContextInputArgs(ctx),
171
172
  rowSelectionProps: {
172
173
  type: 'checkbox',
173
- defaultSelectedRows: () => blockModel?.resource?.getData?.() || [],
174
174
  renderCell: undefined,
175
175
  selectedRowKeys: undefined,
176
176
  onChange: (_, selectedRows) => {
@@ -33,6 +33,20 @@ export const getAssociationTargetResourceSettings = (ctx: FlowModelContext | any
33
33
  };
34
34
  };
35
35
 
36
+ export const getAssociationSelectorContextInputArgs = (ctx: FlowModelContext | any) => {
37
+ const blockModel = ctx?.blockModel || ctx?.model?.context?.blockModel;
38
+ const association = blockModel?.association;
39
+ const resourceSettings = getAssociationBlockResourceSettings(ctx);
40
+ const sourceId = blockModel?.resource?.getSourceId?.() ?? resourceSettings?.sourceId;
41
+ const associatedRecords = blockModel?.resource?.getData?.() || [];
42
+
43
+ return {
44
+ collectionField: association,
45
+ sourceId,
46
+ associatedRecords,
47
+ };
48
+ };
49
+
36
50
  const callAssociationResourceAction = async (resource: any, action: 'add' | 'remove', values: any[]) => {
37
51
  if (typeof resource?.[action] === 'function') {
38
52
  return await resource[action]({ values });
@@ -16,6 +16,7 @@ import {
16
16
  AssociateActionModel,
17
17
  CollectionActionGroupModel,
18
18
  DisassociateActionModel,
19
+ getAssociationSelectorContextInputArgs,
19
20
  getAssociationTargetResourceSettings,
20
21
  PopupActionModel,
21
22
  RecordActionGroupModel,
@@ -87,6 +88,68 @@ describe('association action models', () => {
87
88
  });
88
89
  });
89
90
 
91
+ it('passes one-to-many association context to selector table blocks', () => {
92
+ const association = {
93
+ name: 'orders',
94
+ interface: 'o2m',
95
+ target: 'orders',
96
+ sourceKey: 'id',
97
+ foreignKey: 'userId',
98
+ };
99
+ const ctx: any = {
100
+ blockModel: {
101
+ association,
102
+ resource: {
103
+ getSourceId: () => 362872646860800,
104
+ },
105
+ getResourceSettingsInitParams: () => ({
106
+ dataSourceKey: 'main',
107
+ collectionName: 'orders',
108
+ associationName: 'users.orders',
109
+ sourceId: '{{ctx.popup.record.id}}',
110
+ }),
111
+ },
112
+ };
113
+
114
+ expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
115
+ collectionField: association,
116
+ sourceId: 362872646860800,
117
+ associatedRecords: [],
118
+ });
119
+ });
120
+
121
+ it('passes current associated records to selector table blocks', () => {
122
+ const associatedRecords = [{ id: 11 }, { id: 12 }];
123
+ const association = {
124
+ name: 'tags',
125
+ interface: 'm2m',
126
+ target: 'tags',
127
+ sourceKey: 'id',
128
+ targetKey: 'id',
129
+ };
130
+ const ctx: any = {
131
+ blockModel: {
132
+ association,
133
+ resource: {
134
+ getSourceId: () => 1,
135
+ getData: () => associatedRecords,
136
+ },
137
+ getResourceSettingsInitParams: () => ({
138
+ dataSourceKey: 'main',
139
+ collectionName: 'tags',
140
+ associationName: 'posts.tags',
141
+ sourceId: '{{ctx.popup.record.id}}',
142
+ }),
143
+ },
144
+ };
145
+
146
+ expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
147
+ collectionField: association,
148
+ sourceId: 1,
149
+ associatedRecords,
150
+ });
151
+ });
152
+
90
153
  it('shows Associate only in collection actions of association blocks', async () => {
91
154
  const engine = createEngine();
92
155
 
@@ -662,6 +662,9 @@ CollectionBlockModel.registerFlow({
662
662
  async handler(ctx) {
663
663
  const blockModel = ctx.model as CollectionBlockModel;
664
664
  const filterManager: FilterManager = ctx.model.context.filterManager;
665
+ const preparedFilterBlocks = filterManager?.prepareFiltersForTarget
666
+ ? await filterManager.prepareFiltersForTarget(ctx.model.uid)
667
+ : undefined;
665
668
  if (filterManager) {
666
669
  filterManager.bindToTarget(ctx.model.uid);
667
670
  }
@@ -679,6 +682,10 @@ CollectionBlockModel.registerFlow({
679
682
  return;
680
683
  }
681
684
 
685
+ preparedFilterBlocks?.forEach((filterBlockModel: any) => {
686
+ filterBlockModel?.markInitialTargetRefreshHandled?.(ctx.model.uid);
687
+ });
688
+
682
689
  if (ctx.model.isManualRefresh) {
683
690
  ctx.model.resource.loading = false;
684
691
  } else {
@@ -162,7 +162,7 @@ RootPageModel.registerFlow({
162
162
  const route = ctx.routeRepository.getRouteBySchemaUid(ctx.model.parentId);
163
163
  ctx.model.setProps('routeId', route?.id);
164
164
  const routes: NocoBaseDesktopRoute[] = _.castArray(route?.children);
165
- for (const route of routes.sort((a, b) => a.sort - b.sort)) {
165
+ for (const route of routes.filter(Boolean).sort((a, b) => (a.sort || 0) - (b.sort || 0))) {
166
166
  // 过滤掉隐藏的路由
167
167
  if (route.hideInMenu) {
168
168
  continue;
@@ -32,7 +32,7 @@ import { BlockSceneEnum } from '../../base/BlockModel';
32
32
  import { FilterBlockModel } from '../../base/FilterBlockModel';
33
33
  import { FormComponent } from '../form/FormBlockModel';
34
34
  import { isEmptyValue } from '../form/value-runtime/utils';
35
- import { FilterManager } from '../filter-manager/FilterManager';
35
+ import { FilterManager, type RefreshTargetsByFilterOptions } from '../filter-manager/FilterManager';
36
36
  import { FilterFormItemModel } from './FilterFormItemModel';
37
37
  import { clearLegacyDefaultValuesFromFilterFormModel } from './legacyDefaultValueMigration';
38
38
  import { findFormItemModelByFieldPath } from '../../../internal/utils/modelUtils';
@@ -54,6 +54,8 @@ export class FilterFormBlockModel extends FilterBlockModel<{
54
54
  autoTriggerFilter = true;
55
55
 
56
56
  private removeTargetBlockListener?: () => void;
57
+ private initialDefaultsPromise?: Promise<void>;
58
+ private initialRefreshHandledTargetIds = new Set<string>();
57
59
 
58
60
  get form() {
59
61
  return this.context.form;
@@ -82,13 +84,13 @@ export class FilterFormBlockModel extends FilterBlockModel<{
82
84
  this.context.defineProperty('blockModel', {
83
85
  value: this,
84
86
  });
85
- this.context.defineMethod('refreshTargets', async () => {
87
+ this.context.defineMethod('refreshTargets', async (options?: RefreshTargetsByFilterOptions) => {
86
88
  const gridModel = this.subModels.grid;
87
89
  const fieldModels: FilterFormItemModel[] = gridModel.subModels.items;
88
- if (fieldModels) {
89
- fieldModels.forEach((fieldModel) => {
90
- fieldModel?.doFilter?.();
91
- });
90
+ const filterIds = fieldModels?.map((fieldModel) => fieldModel?.uid).filter(Boolean);
91
+ if (filterIds?.length) {
92
+ const filterManager: FilterManager = this.context.filterManager;
93
+ await filterManager.refreshTargetsByFilter(filterIds, options);
92
94
  }
93
95
  });
94
96
  }
@@ -124,9 +126,31 @@ export class FilterFormBlockModel extends FilterBlockModel<{
124
126
  }
125
127
 
126
128
  private async applyDefaultsAndInitialFilter() {
127
- await this.ensureFilterItemsBeforeRender();
128
- await this.applyFormDefaultValues();
129
- await this.context.refreshTargets?.();
129
+ const prepared = await this.prepareInitialFilterValues();
130
+ if (prepared) {
131
+ await this.context.refreshTargets?.({ excludeTargetIds: this.initialRefreshHandledTargetIds });
132
+ }
133
+ }
134
+
135
+ async prepareInitialFilterValues() {
136
+ if (!this.form) {
137
+ return false;
138
+ }
139
+
140
+ if (!this.initialDefaultsPromise) {
141
+ this.initialDefaultsPromise = (async () => {
142
+ await this.ensureFilterItemsBeforeRender();
143
+ await this.applyFormDefaultValues();
144
+ })();
145
+ }
146
+
147
+ await this.initialDefaultsPromise;
148
+ return true;
149
+ }
150
+
151
+ markInitialTargetRefreshHandled(targetId: string) {
152
+ if (!targetId) return;
153
+ this.initialRefreshHandledTargetIds.add(targetId);
130
154
  }
131
155
 
132
156
  private async ensureFilterItemsBeforeRender() {