@nocobase/client-v2 2.1.0-beta.29 → 2.1.0-beta.32

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 (100) 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/actions/index.d.ts +1 -1
  10. package/es/flow/actions/linkageRules.d.ts +2 -0
  11. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +4 -0
  12. package/es/flow/admin-shell/admin-layout/AdminLayoutModel.d.ts +7 -0
  13. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +5 -0
  14. package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
  15. package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
  16. package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
  17. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
  18. package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
  19. package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
  20. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +3 -0
  21. package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +1 -1
  22. package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
  23. package/es/flow-compat/passwordUtils.d.ts +1 -1
  24. package/es/index.mjs +122 -106
  25. package/es/utils/remotePlugins.d.ts +0 -4
  26. package/lib/index.js +121 -105
  27. package/package.json +6 -5
  28. package/src/BaseApplication.tsx +14 -8
  29. package/src/PluginManager.ts +1 -0
  30. package/src/__tests__/app.test.tsx +28 -1
  31. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +67 -46
  32. package/src/__tests__/remotePlugins.test.ts +29 -18
  33. package/src/__tests__/settings-center.test.tsx +30 -0
  34. package/src/components/form/DrawerFormLayout.tsx +103 -0
  35. package/src/components/form/EnvVariableInput.tsx +126 -0
  36. package/src/components/form/FileSizeInput.tsx +105 -0
  37. package/src/components/form/createFormRegistry.ts +60 -0
  38. package/src/components/form/index.tsx +14 -0
  39. package/src/components/index.ts +1 -1
  40. package/src/flow/__tests__/FlowRoute.test.tsx +4 -5
  41. package/src/flow/actions/__tests__/actionLinkageRules.race.repro.test.ts +199 -0
  42. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
  43. package/src/flow/actions/__tests__/linkageRules.formValueDrivenRefresh.test.ts +6 -1
  44. package/src/flow/actions/__tests__/linkageRules.menu.test.ts +90 -0
  45. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
  46. package/src/flow/actions/index.ts +2 -0
  47. package/src/flow/actions/linkageRules.tsx +316 -280
  48. package/src/flow/actions/linkageRulesFormValueRefresh.ts +2 -8
  49. package/src/flow/actions/setTargetDataScope.tsx +32 -3
  50. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +8 -1
  51. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +70 -12
  52. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +26 -87
  53. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +11 -0
  54. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -1
  55. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +292 -31
  56. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +50 -12
  57. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +77 -56
  58. package/src/flow/components/AdminLayout.tsx +2 -2
  59. package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
  60. package/src/flow/components/FlowRoute.tsx +17 -4
  61. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
  62. package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
  63. package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
  64. package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
  65. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
  66. package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
  67. package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
  68. package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
  69. package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
  70. package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
  71. package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
  72. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
  73. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
  74. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
  75. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
  76. package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
  77. package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
  78. package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
  79. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -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/table/TableBlockModel.tsx +11 -6
  83. package/src/flow/models/blocks/table/TableColumnModel.tsx +3 -0
  84. package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
  85. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
  86. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +96 -1
  87. package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
  88. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -0
  89. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +7 -0
  90. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +4 -1
  91. package/src/flow/models/fields/ClickableFieldModel.tsx +9 -4
  92. package/src/flow/models/fields/DisplayTitleFieldModel.tsx +12 -4
  93. package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
  94. package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +23 -0
  95. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
  96. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
  97. package/src/flow/models/utils/displayValueUtils.ts +57 -0
  98. package/src/flow/system-settings/useSystemSettings.tsx +36 -1
  99. package/src/utils/globalDeps.ts +2 -0
  100. package/src/utils/remotePlugins.ts +7 -27
@@ -0,0 +1,105 @@
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 { css } from '@emotion/css';
11
+ import { InputNumber, Select, Space } from 'antd';
12
+ import React, { useCallback } from 'react';
13
+
14
+ const DEFAULT_MIN = 1;
15
+ const DEFAULT_MAX = Number.POSITIVE_INFINITY;
16
+ const DEFAULT_VALUE = 1024 * 1024 * 20;
17
+
18
+ const UNIT_OPTIONS = [
19
+ { value: 1, label: 'Byte' },
20
+ { value: 1024, label: 'KB' },
21
+ { value: 1024 * 1024, label: 'MB' },
22
+ { value: 1024 * 1024 * 1024, label: 'GB' },
23
+ ];
24
+
25
+ // Mirrors v1's `.auto-width` rule registered globally on FormItem: shrink the
26
+ // antd control to its content width while keeping a sensible minimum.
27
+ const autoWidthClassName = css`
28
+ &.ant-input-number,
29
+ &.ant-select {
30
+ width: auto;
31
+ min-width: 6em;
32
+ }
33
+ `;
34
+
35
+ function getUnitOption(value: number, defaultUnit = 1024 * 1024) {
36
+ const size = value || defaultUnit;
37
+ for (let i = UNIT_OPTIONS.length - 1; i >= 0; i -= 1) {
38
+ const option = UNIT_OPTIONS[i];
39
+ if (size % option.value === 0) {
40
+ return option;
41
+ }
42
+ }
43
+ return UNIT_OPTIONS[0];
44
+ }
45
+
46
+ function clampSize(value: number, min: number, max: number) {
47
+ return Math.min(Math.max(min, value), max);
48
+ }
49
+
50
+ export interface FileSizeInputProps {
51
+ value?: number;
52
+ onChange?: (value?: number) => void;
53
+ disabled?: boolean;
54
+ /** Minimum byte size. Empty / below-min input snaps to this on blur. Defaults to 1. */
55
+ min?: number;
56
+ /** Maximum byte size. Defaults to `Number.POSITIVE_INFINITY`. */
57
+ max?: number;
58
+ /** Default byte size used to derive the initial unit shown when the field is empty. Defaults to 20 MB. */
59
+ defaultValue?: number;
60
+ }
61
+
62
+ /**
63
+ * Byte-valued size input paired with a unit selector (Byte / KB / MB / GB).
64
+ * The persisted value is always normalized to bytes; the displayed number is
65
+ * derived from the picked unit. Useful for fields like file-size limits or
66
+ * memory quotas where the natural input unit varies by magnitude.
67
+ */
68
+ export function FileSizeInput(props: FileSizeInputProps) {
69
+ const min = props.min ?? DEFAULT_MIN;
70
+ const max = props.max ?? DEFAULT_MAX;
71
+ const defaultValue = props.defaultValue ?? DEFAULT_VALUE;
72
+ const unit = getUnitOption(props.value ?? defaultValue);
73
+ const value = props.value == null ? props.value : props.value / unit.value;
74
+
75
+ const handleBlur = useCallback(() => {
76
+ if (props.value == null || props.value < min) {
77
+ props.onChange?.(min);
78
+ }
79
+ }, [props, min]);
80
+
81
+ return (
82
+ <Space.Compact>
83
+ <InputNumber
84
+ value={value}
85
+ disabled={props.disabled}
86
+ defaultValue={defaultValue / getUnitOption(defaultValue).value}
87
+ step={1}
88
+ className={autoWidthClassName}
89
+ onBlur={handleBlur}
90
+ onChange={(nextValue) => {
91
+ props.onChange?.(nextValue == null ? undefined : clampSize(Number(nextValue) * unit.value, min, max));
92
+ }}
93
+ />
94
+ <Select
95
+ disabled={props.disabled}
96
+ options={UNIT_OPTIONS}
97
+ value={unit.value}
98
+ className={autoWidthClassName}
99
+ onChange={(nextUnit) => {
100
+ props.onChange?.(value == null ? undefined : clampSize(Number(value) * nextUnit, min, max));
101
+ }}
102
+ />
103
+ </Space.Compact>
104
+ );
105
+ }
@@ -0,0 +1,60 @@
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 interface FormRegistryEntry {
11
+ name: string;
12
+ }
13
+
14
+ export interface FormRegistry<T extends FormRegistryEntry> {
15
+ readonly namespace: string;
16
+ register(entry: T): void;
17
+ unregister(name: string): boolean;
18
+ get(name: string): T | undefined;
19
+ has(name: string): boolean;
20
+ list(): T[];
21
+ }
22
+
23
+ /**
24
+ * Create an isolated, namespaced registry of form-like entries.
25
+ *
26
+ * Each call returns a fresh registry instance backed by its own closure-scoped
27
+ * `Map`, so plugins do not share state across namespaces. Plugins that need an
28
+ * extension point for form-shaped registrations (e.g. storage configuration
29
+ * forms, data-source connection forms) can build their own typed registry on
30
+ * top of this primitive instead of re-implementing the same boilerplate.
31
+ *
32
+ * Re-registering the same `name` overwrites the previous entry and emits a
33
+ * console warning. This is intentional so HMR / hot reload works without
34
+ * throwing, while still surfacing unintended duplicates during development.
35
+ */
36
+ export function createFormRegistry<T extends FormRegistryEntry>(namespace: string): FormRegistry<T> {
37
+ const entries = new Map<string, T>();
38
+
39
+ return {
40
+ namespace,
41
+ register(entry) {
42
+ if (entries.has(entry.name)) {
43
+ console.warn(`[${namespace}] entry "${entry.name}" already registered, overwriting.`);
44
+ }
45
+ entries.set(entry.name, entry);
46
+ },
47
+ unregister(name) {
48
+ return entries.delete(name);
49
+ },
50
+ get(name) {
51
+ return entries.get(name);
52
+ },
53
+ has(name) {
54
+ return entries.has(name);
55
+ },
56
+ list() {
57
+ return Array.from(entries.values());
58
+ },
59
+ };
60
+ }
@@ -0,0 +1,14 @@
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 './createFormRegistry';
11
+ export * from './DrawerFormLayout';
12
+ export * from './EnvVariableInput';
13
+ export * from './FileSizeInput';
14
+ export * from './JsonTextArea';
@@ -9,6 +9,6 @@
9
9
 
10
10
  export * from './AppComponents';
11
11
  export * from './BlankComponent';
12
+ export * from './form';
12
13
  export * from './Icon';
13
- export * from './form/JsonTextArea';
14
14
  export * from './RouterContextCleaner';
@@ -10,7 +10,7 @@
10
10
  import React from 'react';
11
11
  import { describe, it, expect, vi, beforeEach } from 'vitest';
12
12
  import { MemoryRouter, Route, Routes } from 'react-router-dom';
13
- import { render, waitFor } from '@testing-library/react';
13
+ import { render, screen, waitFor } from '@testing-library/react';
14
14
  import { FlowEngine, FlowEngineProvider, type FlowModel } from '@nocobase/flow-engine';
15
15
  import FlowRoute from '../components/FlowRoute';
16
16
 
@@ -219,7 +219,7 @@ describe('FlowRoute', () => {
219
219
  });
220
220
  });
221
221
 
222
- it('should replace to legacy page when current route is page', async () => {
222
+ it('should show 404 when current route is a legacy page in v2 runtime', async () => {
223
223
  const originalLocation = window.location;
224
224
  const replace = vi.fn();
225
225
  Object.defineProperty(window, 'location', {
@@ -271,9 +271,8 @@ describe('FlowRoute', () => {
271
271
  </FlowEngineProvider>,
272
272
  );
273
273
 
274
- await waitFor(() => {
275
- expect(replace).toHaveBeenCalledWith('/admin/test-page/tabs/tab-1?from=direct#dialog');
276
- });
274
+ expect(await screen.findByText('404')).toBeInTheDocument();
275
+ expect(replace).not.toHaveBeenCalled();
277
276
  expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
278
277
  expect(adminLayoutModel.updateRoutePage).not.toHaveBeenCalled();
279
278
  } finally {
@@ -0,0 +1,199 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { actionLinkageRules } from '../linkageRules';
12
+
13
+ function createActionModel() {
14
+ const model: any = {
15
+ uid: 'edit-action',
16
+ hidden: false,
17
+ props: {
18
+ title: 'Edit',
19
+ },
20
+ context: {},
21
+ setProps: vi.fn((keyOrProps: string | Record<string, any>, value?: any) => {
22
+ if (typeof keyOrProps === 'string') {
23
+ model.props[keyOrProps] = value;
24
+ return;
25
+ }
26
+ model.props = {
27
+ ...model.props,
28
+ ...keyOrProps,
29
+ };
30
+ }),
31
+ };
32
+
33
+ return model;
34
+ }
35
+
36
+ function createLinkageParams(actions: any[] = []) {
37
+ return {
38
+ value: actions.length
39
+ ? [
40
+ {
41
+ key: 'hide-edit',
42
+ title: 'hide edit',
43
+ enable: true,
44
+ condition: {
45
+ logic: '$and',
46
+ items: [],
47
+ },
48
+ actions,
49
+ },
50
+ ]
51
+ : [],
52
+ };
53
+ }
54
+
55
+ function createRuntime(model: any, options: { pauseHiddenAction?: boolean } = {}) {
56
+ const { pauseHiddenAction = true } = options;
57
+ let releaseHiddenAction: () => void = () => {};
58
+ let hiddenActionCount = 0;
59
+ const hiddenActionWaiters: Array<{ count: number; resolve: () => void }> = [];
60
+ const waitForHiddenActionCount = (count: number) => {
61
+ if (hiddenActionCount >= count) {
62
+ return Promise.resolve();
63
+ }
64
+ return new Promise<void>((resolve) => {
65
+ hiddenActionWaiters.push({ count, resolve });
66
+ });
67
+ };
68
+ const hiddenActionReachedPromise = waitForHiddenActionCount(1);
69
+ const releaseHiddenActionPromise = new Promise<void>((resolve) => {
70
+ releaseHiddenAction = resolve;
71
+ });
72
+ const notifyHiddenActionReached = () => {
73
+ hiddenActionCount += 1;
74
+ for (let i = hiddenActionWaiters.length - 1; i >= 0; i--) {
75
+ const waiter = hiddenActionWaiters[i];
76
+ if (hiddenActionCount < waiter.count) continue;
77
+ hiddenActionWaiters.splice(i, 1);
78
+ waiter.resolve();
79
+ }
80
+ };
81
+
82
+ const ctx: any = {
83
+ flowKey: 'buttonSettings',
84
+ model,
85
+ app: {
86
+ jsonLogic: {
87
+ apply: vi.fn(() => true),
88
+ },
89
+ },
90
+ t: (s: string) => s,
91
+ resolveJsonTemplate: vi.fn(async (value: any) => value),
92
+ getAction: (name: string) => {
93
+ if (name !== 'linkageSetActionProps') return undefined;
94
+
95
+ return {
96
+ handler: async (_ctx: any, params: any) => {
97
+ params.setProps(model, {
98
+ hiddenModel: params.value === 'hidden',
99
+ disabled: false,
100
+ hiddenText: false,
101
+ });
102
+ notifyHiddenActionReached();
103
+ if (pauseHiddenAction) {
104
+ await releaseHiddenActionPromise;
105
+ }
106
+ },
107
+ };
108
+ },
109
+ };
110
+
111
+ return {
112
+ ctx,
113
+ hiddenActionReachedPromise,
114
+ waitForHiddenActionCount,
115
+ releaseHiddenAction,
116
+ };
117
+ }
118
+
119
+ describe('actionLinkageRules props patch isolation', () => {
120
+ async function runReproOnce() {
121
+ const model = createActionModel();
122
+ const { ctx, hiddenActionReachedPromise, releaseHiddenAction } = createRuntime(model);
123
+
124
+ const hideRun = actionLinkageRules.handler(
125
+ ctx,
126
+ createLinkageParams([
127
+ {
128
+ name: 'linkageSetActionProps',
129
+ params: {
130
+ value: 'hidden',
131
+ },
132
+ },
133
+ ]),
134
+ );
135
+
136
+ await hiddenActionReachedPromise;
137
+ expect(model.hidden).toBe(false);
138
+
139
+ await actionLinkageRules.handler(ctx, createLinkageParams());
140
+ expect(model.hidden).toBe(false);
141
+
142
+ releaseHiddenAction();
143
+ await hideRun;
144
+
145
+ expect(model.hidden).toBe(true);
146
+ }
147
+
148
+ it('keeps a concurrent empty run from clearing another run props patch', async () => {
149
+ for (let i = 0; i < 50; i++) {
150
+ await runReproOnce();
151
+ }
152
+ });
153
+
154
+ it('still restores original props when a later completed run has no matched actions', async () => {
155
+ const model = createActionModel();
156
+ const { ctx } = createRuntime(model, { pauseHiddenAction: false });
157
+
158
+ await actionLinkageRules.handler(
159
+ ctx,
160
+ createLinkageParams([
161
+ {
162
+ name: 'linkageSetActionProps',
163
+ params: {
164
+ value: 'hidden',
165
+ },
166
+ },
167
+ ]),
168
+ );
169
+ expect(model.hidden).toBe(true);
170
+
171
+ await actionLinkageRules.handler(ctx, createLinkageParams());
172
+ expect(model.hidden).toBe(false);
173
+ });
174
+
175
+ it('keeps concurrent matched runs from clearing each other props patch', async () => {
176
+ const model = createActionModel();
177
+ const { ctx, waitForHiddenActionCount, releaseHiddenAction } = createRuntime(model);
178
+ const params = createLinkageParams([
179
+ {
180
+ name: 'linkageSetActionProps',
181
+ params: {
182
+ value: 'hidden',
183
+ },
184
+ },
185
+ ]);
186
+
187
+ const firstRun = actionLinkageRules.handler(ctx, params);
188
+ await waitForHiddenActionCount(1);
189
+
190
+ const secondRun = actionLinkageRules.handler(ctx, params);
191
+ await waitForHiddenActionCount(2);
192
+
193
+ releaseHiddenAction();
194
+ await firstRun;
195
+ await secondRun;
196
+
197
+ expect(model.hidden).toBe(true);
198
+ });
199
+ });
@@ -8,10 +8,31 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, it, vi } from 'vitest';
11
+ import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
11
12
  import { dataScope } from '../dataScope';
12
13
  import { normalizeDataScopeFilter } from '../dataScopeFilter';
13
14
  import { setTargetDataScope } from '../setTargetDataScope';
14
15
 
16
+ function createSetTargetDataScopeContext(resource: any, options: { selected?: boolean; resolvedValue?: any } = {}) {
17
+ const targetModel = { resource };
18
+ const inputArgs = options.selected === undefined ? undefined : { selected: options.selected };
19
+
20
+ return {
21
+ ...(inputArgs ? { inputArgs } : {}),
22
+ model: {
23
+ uid: 'action-1',
24
+ scheduleModelOperation: vi.fn((_uid, callback) => callback(targetModel)),
25
+ },
26
+ resolveJsonTemplate: vi.fn(async (template) => ({
27
+ ...template,
28
+ filter: {
29
+ ...template.filter,
30
+ items: [{ ...template.filter.items[0], value: options.resolvedValue }],
31
+ },
32
+ })),
33
+ };
34
+ }
35
+
15
36
  describe('normalizeDataScopeFilter', () => {
16
37
  it('keeps null when a right-side variable resolves to empty', () => {
17
38
  const rawFilter = {
@@ -125,34 +146,92 @@ describe('normalizeDataScopeFilter', () => {
125
146
  hasData: vi.fn(() => false),
126
147
  refresh: vi.fn(),
127
148
  };
128
- const targetModel = { resource };
129
- const ctx = {
130
- model: {
131
- uid: 'action-1',
132
- scheduleModelOperation: vi.fn((_uid, callback) => callback(targetModel)),
149
+ const ctx = createSetTargetDataScopeContext(resource);
150
+ const params = {
151
+ targetBlockUid: 'target-1',
152
+ filter: {
153
+ logic: '$and',
154
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
133
155
  },
134
- resolveJsonTemplate: vi.fn(async (template) => ({
135
- ...template,
136
- filter: {
137
- ...template.filter,
138
- items: [{ ...template.filter.items[0], value: undefined }],
139
- },
140
- })),
141
156
  };
157
+
158
+ await (setTargetDataScope as any).handler(ctx, params);
159
+
160
+ expect(ctx.model.scheduleModelOperation).toHaveBeenCalledWith('target-1', expect.any(Function));
161
+ expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
162
+ $and: [{ departmentId: { $eq: null } }],
163
+ });
164
+ expect(resource.removeFilterGroup).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it('setTargetDataScope handler removes clicked-row data scope when row click deselects', async () => {
168
+ const resource = {
169
+ addFilterGroup: vi.fn(),
170
+ removeFilterGroup: vi.fn(),
171
+ hasData: vi.fn(() => true),
172
+ refresh: vi.fn(),
173
+ };
174
+ const ctx = createSetTargetDataScopeContext(resource, { selected: false, resolvedValue: null });
142
175
  const params = {
143
176
  targetBlockUid: 'target-1',
144
177
  filter: {
145
178
  logic: '$and',
146
- items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.formValues.department.id }}' }],
179
+ items: [{ path: 'id', operator: '$eq', value: '{{ ctx.clickedRowRecord.id }}' }],
147
180
  },
148
181
  };
149
182
 
150
183
  await (setTargetDataScope as any).handler(ctx, params);
151
184
 
152
185
  expect(ctx.model.scheduleModelOperation).toHaveBeenCalledWith('target-1', expect.any(Function));
186
+ expect(resource.removeFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1');
187
+ expect(resource.addFilterGroup).not.toHaveBeenCalled();
188
+ expect(resource.refresh).toHaveBeenCalledTimes(1);
189
+ });
190
+
191
+ it('setTargetDataScope handler keeps null clicked-row field values while row is selected', async () => {
192
+ const resource = {
193
+ addFilterGroup: vi.fn(),
194
+ removeFilterGroup: vi.fn(),
195
+ hasData: vi.fn(() => false),
196
+ refresh: vi.fn(),
197
+ };
198
+ const ctx = createSetTargetDataScopeContext(resource, { selected: true, resolvedValue: null });
199
+ const params = {
200
+ targetBlockUid: 'target-1',
201
+ filter: {
202
+ logic: '$and',
203
+ items: [{ path: 'departmentId', operator: '$eq', value: '{{ ctx.clickedRowRecord.departmentId }}' }],
204
+ },
205
+ };
206
+
207
+ await (setTargetDataScope as any).handler(ctx, params);
208
+
153
209
  expect(resource.addFilterGroup).toHaveBeenCalledWith('setTargetDataScope_action-1', {
154
210
  $and: [{ departmentId: { $eq: null } }],
155
211
  });
156
212
  expect(resource.removeFilterGroup).not.toHaveBeenCalled();
157
213
  });
214
+
215
+ it('setTargetDataScope handler refreshes empty loaded target while preserving its own data scope', async () => {
216
+ const engine = new FlowEngine();
217
+ const resource = engine.createResource(MultiRecordResource);
218
+ resource.addFilterGroup('target-table', { status: { $eq: 'active' } });
219
+ resource.addFilterGroup('setTargetDataScope_action-1', { id: { $eq: 1 } });
220
+ resource.setData([]);
221
+ resource.setMeta({ count: 0, hasNext: false });
222
+ const refresh = vi.spyOn(resource, 'refresh').mockResolvedValue(undefined);
223
+ const ctx = createSetTargetDataScopeContext(resource, { selected: false, resolvedValue: null });
224
+ const params = {
225
+ targetBlockUid: 'target-1',
226
+ filter: {
227
+ logic: '$and',
228
+ items: [{ path: 'id', operator: '$eq', value: '{{ ctx.clickedRowRecord.id }}' }],
229
+ },
230
+ };
231
+
232
+ await (setTargetDataScope as any).handler(ctx, params);
233
+
234
+ expect(resource.getRequestParameter('filter')).toBe(JSON.stringify({ $and: [{ status: { $eq: 'active' } }] }));
235
+ expect(refresh).toHaveBeenCalledTimes(1);
236
+ });
158
237
  });
@@ -26,7 +26,12 @@ function createRule(overrides: any = {}) {
26
26
 
27
27
  function createRuntime(
28
28
  params: any,
29
- options: { fieldIndex?: string[]; fieldIndexRef?: { current: string[] }; actionHandler?: any; engineEmitter?: any } = {},
29
+ options: {
30
+ fieldIndex?: string[];
31
+ fieldIndexRef?: { current: string[] };
32
+ actionHandler?: any;
33
+ engineEmitter?: any;
34
+ } = {},
30
35
  ) {
31
36
  const formEmitter = new EventEmitter();
32
37
  const formBlock: any = {
@@ -0,0 +1,90 @@
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 { ActionScene } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+
13
+ import * as registeredActions from '../index';
14
+ import { linkageRunjs, linkageSetMenuItemProps, menuLinkageRules } from '../linkageRules';
15
+
16
+ describe('menu linkage rules', () => {
17
+ it('should register menu linkage actions from the actions entry', () => {
18
+ expect(registeredActions.menuLinkageRules).toBe(menuLinkageRules);
19
+ expect(registeredActions.linkageSetMenuItemProps).toBe(linkageSetMenuItemProps);
20
+ });
21
+
22
+ it('should expose menu state and runjs actions in ui schema', () => {
23
+ const schema = menuLinkageRules.uiSchema?.({
24
+ getActions: () =>
25
+ new Map([
26
+ ['linkageSetMenuItemProps', linkageSetMenuItemProps],
27
+ ['linkageRunjs', linkageRunjs],
28
+ [
29
+ 'actionOnly',
30
+ {
31
+ name: 'actionOnly',
32
+ scene: ActionScene.ACTION_LINKAGE_RULES,
33
+ },
34
+ ],
35
+ ]),
36
+ } as any) as any;
37
+
38
+ expect(schema?.value?.['x-component-props']?.supportedActions).toEqual(['linkageSetMenuItemProps', 'linkageRunjs']);
39
+ });
40
+
41
+ it('should set menu model hidden through common linkage handler', async () => {
42
+ const setProps = vi.fn(function (this: any, props: any) {
43
+ this.props = {
44
+ ...this.props,
45
+ ...props,
46
+ };
47
+ });
48
+ const setHidden = vi.fn(function (this: any, value: boolean) {
49
+ this.hidden = value;
50
+ });
51
+ const model: any = {
52
+ uid: 'menu-item-1',
53
+ props: {},
54
+ hidden: false,
55
+ __allModels: [],
56
+ setProps,
57
+ setHidden,
58
+ };
59
+ const ctx: any = {
60
+ app: { jsonLogic: { apply: () => true } },
61
+ model,
62
+ t: (text: string) => text,
63
+ getAction: (name: string) => (name === 'linkageSetMenuItemProps' ? linkageSetMenuItemProps : undefined),
64
+ resolveJsonTemplate: vi.fn(async (value: any) => value),
65
+ };
66
+
67
+ await menuLinkageRules.handler(ctx, {
68
+ value: [
69
+ {
70
+ key: 'r1',
71
+ title: 'Hide menu item',
72
+ enable: true,
73
+ condition: { logic: '$and', items: [] },
74
+ actions: [
75
+ {
76
+ key: 'a1',
77
+ name: 'linkageSetMenuItemProps',
78
+ params: {
79
+ value: 'hidden',
80
+ },
81
+ },
82
+ ],
83
+ },
84
+ ],
85
+ });
86
+
87
+ expect(setHidden).toHaveBeenCalledWith(true);
88
+ expect(model.hidden).toBe(true);
89
+ });
90
+ });