@nocobase/client-v2 2.2.0-alpha.8 → 2.2.0-alpha.9

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 (41) hide show
  1. package/es/APIClient.d.ts +1 -3
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -1
  3. package/es/flow/actions/index.d.ts +1 -1
  4. package/es/flow/actions/linkageRules.d.ts +2 -0
  5. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +1 -0
  6. package/es/flow/components/filter/VariableFilterItem.d.ts +5 -1
  7. package/es/flow/models/base/PageModel/PageModel.d.ts +12 -0
  8. package/es/flow/models/base/PageModel/PageModelTabBar.d.ts +22 -0
  9. package/es/flow/models/base/PageModel/PageTabModel.d.ts +12 -0
  10. package/es/flow-compat/fieldValidationConstants.d.ts +1 -1
  11. package/es/flow-compat/routeTypes.d.ts +1 -0
  12. package/es/index.mjs +103 -102
  13. package/lib/index.js +117 -116
  14. package/lib/locale/languageCodes.js +2 -1
  15. package/package.json +7 -7
  16. package/src/APIClient.ts +1 -10
  17. package/src/Application.tsx +4 -0
  18. package/src/__tests__/app.test.tsx +18 -0
  19. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +28 -0
  20. package/src/collection-manager/field-validation.ts +1 -1
  21. package/src/components/form/ScanInput/CodeScanner.tsx +23 -6
  22. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +35 -1
  23. package/src/components/form/ScanInput/useCodeScanner.ts +16 -3
  24. package/src/flow/actions/__tests__/linkageRules.tab.test.ts +171 -0
  25. package/src/flow/actions/index.ts +2 -0
  26. package/src/flow/actions/linkageRules.tsx +86 -11
  27. package/src/flow/components/filter/VariableFilterItem.tsx +20 -5
  28. package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +49 -1
  29. package/src/flow/models/base/PageModel/PageModel.tsx +387 -53
  30. package/src/flow/models/base/PageModel/PageModelTabBar.tsx +114 -0
  31. package/src/flow/models/base/PageModel/PageTabModel.tsx +184 -3
  32. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +790 -10
  33. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.module-isolation.test.tsx +130 -0
  34. package/src/flow/models/base/PageModel/__tests__/PageModelTabBar.test.tsx +118 -0
  35. package/src/flow/models/base/PageModel/__tests__/PageTabModel.test.ts +572 -1
  36. package/src/flow/models/blocks/table/TableBlockModel.tsx +1 -1
  37. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +1 -1
  38. package/src/flow-compat/fieldValidationConstants.ts +1 -1
  39. package/src/flow-compat/routeTypes.ts +1 -0
  40. package/src/locale/languageCodes.ts +2 -1
  41. package/src/nocobase-buildin-plugin/index.tsx +12 -0
@@ -83,7 +83,8 @@ const languageCodes = {
83
83
  "tk-TK": { label: "Turkmen" },
84
84
  "tr-TR": { label: "T\xFCrk\xE7e" },
85
85
  "uk-UA": { label: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430" },
86
- "ur-PK": { label: "O\u02BBzbekcha" },
86
+ "ur-PK": { label: "\u0627\u0631\u062F\u0648" },
87
+ "uz-UZ": { label: "O\u02BBzbekcha" },
87
88
  "vi-VN": { label: "Ti\u1EBFng Vi\u1EC7t" },
88
89
  "zh-CN": { label: "\u7B80\u4F53\u4E2D\u6587" },
89
90
  "zh-HK": { label: "\u7E41\u9AD4\u4E2D\u6587\uFF08\u9999\u6E2F\uFF09" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/client-v2",
3
- "version": "2.2.0-alpha.8",
3
+ "version": "2.2.0-alpha.9",
4
4
  "license": "Apache-2.0",
5
5
  "main": "lib/index.js",
6
6
  "module": "es/index.mjs",
@@ -27,11 +27,11 @@
27
27
  "@formily/antd-v5": "1.2.3",
28
28
  "@formily/react": "^2.2.27",
29
29
  "@formily/shared": "^2.2.27",
30
- "@nocobase/evaluators": "2.2.0-alpha.8",
31
- "@nocobase/flow-engine": "2.2.0-alpha.8",
32
- "@nocobase/sdk": "2.2.0-alpha.8",
33
- "@nocobase/shared": "2.2.0-alpha.8",
34
- "@nocobase/utils": "2.2.0-alpha.8",
30
+ "@nocobase/evaluators": "2.2.0-alpha.9",
31
+ "@nocobase/flow-engine": "2.2.0-alpha.9",
32
+ "@nocobase/sdk": "2.2.0-alpha.9",
33
+ "@nocobase/shared": "2.2.0-alpha.9",
34
+ "@nocobase/utils": "2.2.0-alpha.9",
35
35
  "ahooks": "^3.7.2",
36
36
  "antd": "5.24.2",
37
37
  "antd-style": "3.7.1",
@@ -47,5 +47,5 @@
47
47
  "react-i18next": "^11.15.1",
48
48
  "react-router-dom": "^6.30.1"
49
49
  },
50
- "gitHead": "810c81e5963966bda7ec03b6453a3a80fe60e027"
50
+ "gitHead": "4949bf5ebcd92d77a3a0a718ed578c270e4bd570"
51
51
  }
package/src/APIClient.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { APIClient as APIClientSDK, type APIClientOptions, hasHeaderValue } from '@nocobase/sdk';
10
+ import { APIClient as APIClientSDK, hasHeaderValue } from '@nocobase/sdk';
11
11
 
12
12
  function offsetToTimeZone(offset: number) {
13
13
  const hours = Math.floor(Math.abs(offset));
@@ -23,15 +23,6 @@ function getCurrentTimezone() {
23
23
  }
24
24
 
25
25
  export class APIClient extends APIClientSDK {
26
- appName?: string;
27
-
28
- constructor(options?: APIClientOptions) {
29
- super(options);
30
- if (options && typeof options !== 'function') {
31
- this.appName = options.appName;
32
- }
33
- }
34
-
35
26
  getHostname() {
36
27
  if (process.env.API_BASE_URL) {
37
28
  try {
@@ -46,6 +46,10 @@ export class Application extends BaseApplication<
46
46
 
47
47
  protected createApiClient(options: ApplicationOptions) {
48
48
  return new APIClient({
49
+ // Cross-origin API deployments rely on cookies for auth (e.g. permanent file
50
+ // URLs); trust is enforced server-side via the CORS origin whitelist and the
51
+ // CSRF middleware, not by omitting credentials.
52
+ withCredentials: true,
49
53
  ...options.apiClient,
50
54
  appName: options.name || getSubAppName(options.publicPath),
51
55
  });
@@ -52,6 +52,24 @@ describe('app', () => {
52
52
  expect(app.getHref('/test')).toBe('/test');
53
53
  });
54
54
 
55
+ it('should enable credentials for api clients by default', () => {
56
+ const sameOriginApp = new Application({
57
+ router,
58
+ apiClient: {
59
+ baseURL: '/api/',
60
+ },
61
+ });
62
+ const crossOriginApp = new Application({
63
+ router,
64
+ apiClient: {
65
+ baseURL: 'https://api.example.com/api/',
66
+ },
67
+ });
68
+
69
+ expect(sameOriginApp.apiClient.axios.defaults.withCredentials).toBe(true);
70
+ expect(crossOriginApp.apiClient.axios.defaults.withCredentials).toBe(true);
71
+ });
72
+
55
73
  it('should initialize shared jsonLogic operators', () => {
56
74
  const app = new Application({ router });
57
75
 
@@ -55,6 +55,7 @@ describe('nocobase buildin plugin auth redirect', () => {
55
55
  const originalLocation = globalThis.window.location;
56
56
 
57
57
  beforeEach(() => {
58
+ globalThis.window.localStorage.clear();
58
59
  // These fixtures mount the modern client under the `v2` segment; tell the
59
60
  // runtime-prefix helper so v2-runtime detection matches (server injects it
60
61
  // in production).
@@ -75,6 +76,7 @@ describe('nocobase buildin plugin auth redirect', () => {
75
76
  });
76
77
 
77
78
  afterEach(() => {
79
+ globalThis.window.localStorage.clear();
78
80
  delete (globalThis.window as any).__nocobase_modern_client_prefix__;
79
81
  Object.defineProperty(globalThis.window, 'location', {
80
82
  configurable: true,
@@ -116,6 +118,7 @@ describe('nocobase buildin plugin auth redirect', () => {
116
118
  plugins: [NocoBaseBuildInPlugin as any],
117
119
  router: { type: 'memory', initialEntries: ['/v2/admin'] },
118
120
  });
121
+ app.apiClient.auth.setToken('test-token');
119
122
  app.apiMock.onGet('app:getLang').reply(200, {
120
123
  data: { lang: 'en-US', resources: { client: {} }, cron: {} },
121
124
  });
@@ -130,6 +133,7 @@ describe('nocobase buildin plugin auth redirect', () => {
130
133
  await new Promise((resolve) => setTimeout(resolve, 50));
131
134
  expect(app.router.router.state.location.pathname).toBe('/v2/admin');
132
135
  expect(app.router.router.state.location.search).toBe('');
136
+ expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(0);
133
137
  });
134
138
 
135
139
  it('should redirect unauthenticated v2 root access to v2 signin via <Navigate />', async () => {
@@ -326,6 +330,29 @@ describe('nocobase buildin plugin auth redirect', () => {
326
330
  data: { lang: 'en-US', resources: { client: {} }, cron: {} },
327
331
  });
328
332
  app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
333
+ app.apiMock.onPost('auth:syncCookies').reply(200, { data: { synced: true } });
334
+ const ensureLoaded = vi.spyOn(app.dataSourceManager, 'ensureLoaded').mockResolvedValue(undefined);
335
+
336
+ const Root = app.getRootComponent();
337
+ render(<Root />);
338
+
339
+ expect(await screen.findByText('secure page')).toBeInTheDocument();
340
+ expect(ensureLoaded).toHaveBeenCalledTimes(1);
341
+ expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(1);
342
+ });
343
+
344
+ it('should keep authenticated v2 startup working when cookie bootstrap fails', async () => {
345
+ const app = createMockClient({
346
+ publicPath: '/v2/',
347
+ plugins: [NocoBaseBuildInPlugin as any, AuthBootstrapRoutePlugin as any],
348
+ router: { type: 'memory', initialEntries: ['/v2/secure'] },
349
+ });
350
+ app.apiClient.auth.setToken('test-token');
351
+ app.apiMock.onGet('app:getLang').reply(200, {
352
+ data: { lang: 'en-US', resources: { client: {} }, cron: {} },
353
+ });
354
+ app.apiMock.onGet('/auth:check').reply(200, { data: { id: 1 } });
355
+ app.apiMock.onPost('auth:syncCookies').reply(500, { errors: [{ message: 'cookie sync failed' }] });
329
356
  const ensureLoaded = vi.spyOn(app.dataSourceManager, 'ensureLoaded').mockResolvedValue(undefined);
330
357
 
331
358
  const Root = app.getRootComponent();
@@ -333,6 +360,7 @@ describe('nocobase buildin plugin auth redirect', () => {
333
360
 
334
361
  expect(await screen.findByText('secure page')).toBeInTheDocument();
335
362
  expect(ensureLoaded).toHaveBeenCalledTimes(1);
363
+ expect(app.apiMock.history.post.filter((request) => request.url === 'auth:syncCookies')).toHaveLength(1);
336
364
  });
337
365
 
338
366
  it('should not auth-check or bootstrap authCheck false routes', async () => {
@@ -132,7 +132,7 @@ fieldValidationConfigureRegistry.registerMany([
132
132
  { key: 'uri', label: 'URI', hasValue: false, params: [], paramsType: 'object' },
133
133
  {
134
134
  key: 'multiple',
135
- label: 'Multiple',
135
+ label: 'Multiple of',
136
136
  hasValue: true,
137
137
  params: [{ key: 'base', label: 'Base', componentType: 'inputNumber', required: true }],
138
138
  },
@@ -49,6 +49,21 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
49
49
  message.error(t('Code recognition failed, please scan again'));
50
50
  }, [t]);
51
51
 
52
+ const showCameraStartFailure = useCallback(
53
+ (error: unknown) => {
54
+ const errorName = error instanceof Error ? error.name : '';
55
+ const errorMessage = error instanceof Error ? error.message : '';
56
+ const errorMap: Record<string, string> = {
57
+ NotFoundError: t('No camera device detected'),
58
+ NotAllowedError: t('You have not granted permission to use the camera'),
59
+ };
60
+
61
+ message.error(errorMap[errorName] || errorMessage || t('You have not granted permission to use the camera'));
62
+ onClose();
63
+ },
64
+ [onClose, t],
65
+ );
66
+
52
67
  const handleScanSuccess = useCallback(
53
68
  (text: string) => {
54
69
  onScanSuccess(text);
@@ -64,6 +79,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
64
79
  scanBoxSize,
65
80
  onScanSuccess: handleScanSuccess,
66
81
  onScanFailure: showScanFailure,
82
+ onCameraStartFailure: showCameraStartFailure,
67
83
  });
68
84
 
69
85
  useEffect(() => {
@@ -129,7 +145,7 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
129
145
  inset: 0;
130
146
  z-index: ${token.zIndexPopupBase + 1000};
131
147
  overflow: hidden;
132
- background: ${token.colorBgMask};
148
+ background: #000;
133
149
  `;
134
150
  const scannerWrapperClass = css`
135
151
  position: absolute;
@@ -137,16 +153,17 @@ function CodeScannerContent({ visible, formatsToSupport, onClose, onScanSuccess
137
153
  overflow: hidden;
138
154
  `;
139
155
  const scannerClass = css`
156
+ position: relative;
140
157
  width: 100%;
141
158
  height: 100%;
142
- display: flex;
143
- justify-content: center;
144
- align-items: center;
159
+ overflow: hidden;
145
160
 
146
161
  video {
147
- width: auto !important;
162
+ position: absolute !important;
163
+ inset: 0 !important;
164
+ width: 100% !important;
148
165
  height: 100% !important;
149
- max-width: none !important;
166
+ object-fit: cover !important;
150
167
  }
151
168
 
152
169
  #qr-shaded-region {
@@ -74,13 +74,23 @@ vi.mock('html5-qrcode', () => ({
74
74
 
75
75
  vi.mock('jsqr', () => jsQrMocks);
76
76
 
77
- function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
77
+ function ScannerHost({
78
+ onCameraStartFailure,
79
+ onScanFailure,
80
+ scanBoxSize,
81
+ }: {
82
+ onCameraStartFailure?: (error: unknown) => void;
83
+ onScanFailure?: () => void;
84
+ scanBoxSize?: { width: number; height: number };
85
+ } = {}) {
78
86
  const handleScanSuccess = useCallback(() => undefined, []);
79
87
 
80
88
  useCodeScanner({
81
89
  elementId: 'scanner',
82
90
  enabled: true,
83
91
  scanBoxSize,
92
+ onCameraStartFailure,
93
+ onScanFailure,
84
94
  onScanSuccess: handleScanSuccess,
85
95
  });
86
96
 
@@ -194,6 +204,30 @@ describe('useCodeScanner', () => {
194
204
  expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
195
205
  });
196
206
 
207
+ it('clamps the scan box to the camera viewfinder size', async () => {
208
+ render(<ScannerHost scanBoxSize={{ width: 319, height: 240 }} />);
209
+
210
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
211
+
212
+ const config = mocks.start.mock.calls[0]?.[1] as
213
+ | { qrbox?: (width: number, height: number) => { width: number; height: number } }
214
+ | undefined;
215
+
216
+ expect(config?.qrbox?.(200, 160)).toEqual({ width: 200, height: 160 });
217
+ });
218
+
219
+ it('reports camera start failures through the dedicated handler', async () => {
220
+ const cameraStartError = Object.assign(new Error('Camera failed'), { name: 'NotAllowedError' });
221
+ const handleCameraStartFailure = vi.fn();
222
+ const handleScanFailure = vi.fn();
223
+ mocks.start.mockRejectedValueOnce(cameraStartError);
224
+
225
+ render(<ScannerHost onCameraStartFailure={handleCameraStartFailure} onScanFailure={handleScanFailure} />);
226
+
227
+ await waitFor(() => expect(handleCameraStartFailure).toHaveBeenCalledWith(cameraStartError));
228
+ expect(handleScanFailure).not.toHaveBeenCalled();
229
+ });
230
+
197
231
  it('uses jsQR first for Safari uploaded QR images', async () => {
198
232
  const handleScanSuccess = vi.fn();
199
233
  jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
@@ -25,6 +25,7 @@ type UseCodeScannerOptions = {
25
25
  onScannerSizeChanged?: (size: ScannerSize) => void;
26
26
  onScanSuccess: (text: string) => void;
27
27
  onScanFailure?: () => void;
28
+ onCameraStartFailure?: (error: unknown) => void;
28
29
  };
29
30
 
30
31
  type ImageDataVariant = {
@@ -70,6 +71,13 @@ export function getCodeScanBoxSize(width: number, height: number) {
70
71
  };
71
72
  }
72
73
 
74
+ function clampCodeScanBoxSize(scanBoxSize: ScannerSize, width: number, height: number) {
75
+ return {
76
+ width: Math.min(scanBoxSize.width, width),
77
+ height: Math.min(scanBoxSize.height, height),
78
+ };
79
+ }
80
+
73
81
  async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } = {}) {
74
82
  if (!scanner) {
75
83
  return;
@@ -212,6 +220,7 @@ export function useCodeScanner({
212
220
  onScannerSizeChanged,
213
221
  onScanSuccess,
214
222
  onScanFailure,
223
+ onCameraStartFailure,
215
224
  }: UseCodeScannerOptions) {
216
225
  const [scanner, setScanner] = useState<Html5Qrcode>();
217
226
 
@@ -223,7 +232,7 @@ export function useCodeScanner({
223
232
  fps: 10,
224
233
  qrbox(width, height) {
225
234
  onScannerSizeChanged?.({ width, height });
226
- return scanBoxSize ?? getCodeScanBoxSize(width, height);
235
+ return clampCodeScanBoxSize(scanBoxSize ?? getCodeScanBoxSize(width, height), width, height);
227
236
  },
228
237
  },
229
238
  (decodedText) => {
@@ -273,14 +282,18 @@ export function useCodeScanner({
273
282
  verbose: false,
274
283
  });
275
284
  setScanner(scannerInstance);
276
- startScanCamera(scannerInstance).catch(() => {
285
+ startScanCamera(scannerInstance).catch((error: unknown) => {
286
+ if (onCameraStartFailure) {
287
+ onCameraStartFailure(error);
288
+ return;
289
+ }
277
290
  onScanFailure?.();
278
291
  });
279
292
 
280
293
  return () => {
281
294
  stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
282
295
  };
283
- }, [elementId, enabled, formatsToSupport, onScanFailure, startScanCamera]);
296
+ }, [elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
284
297
 
285
298
  return {
286
299
  startScanFile,
@@ -0,0 +1,171 @@
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 * as linkageActions from '../linkageRules';
15
+
16
+ const tabLinkageRules = (linkageActions as Record<string, unknown>).tabLinkageRules as
17
+ | typeof linkageActions.menuLinkageRules
18
+ | undefined;
19
+ const linkageSetTabProps = (linkageActions as Record<string, unknown>).linkageSetTabProps as
20
+ | typeof linkageActions.linkageSetMenuItemProps
21
+ | undefined;
22
+
23
+ function createRuntime() {
24
+ let matched = true;
25
+ const setProps = vi.fn(function (this: { props: Record<string, unknown> }, props: Record<string, unknown>) {
26
+ this.props = {
27
+ ...this.props,
28
+ ...props,
29
+ };
30
+ });
31
+ const setHidden = vi.fn(function (this: { hidden: boolean }, value: boolean) {
32
+ this.hidden = value;
33
+ });
34
+ const model = {
35
+ uid: 'tab-1',
36
+ props: {} as Record<string, unknown>,
37
+ hidden: false,
38
+ __allModels: [],
39
+ setProps,
40
+ setHidden,
41
+ };
42
+ const ctx = {
43
+ app: { jsonLogic: { apply: () => matched } },
44
+ model,
45
+ t: (text: string) => text,
46
+ getAction: (name: string) => (name === 'linkageSetTabProps' ? linkageSetTabProps : undefined),
47
+ resolveJsonTemplate: vi.fn(async (value: unknown) => value),
48
+ };
49
+
50
+ return {
51
+ ctx,
52
+ model,
53
+ setMatched(value: boolean) {
54
+ matched = value;
55
+ },
56
+ };
57
+ }
58
+
59
+ const hideRule = {
60
+ value: [
61
+ {
62
+ key: 'rule-1',
63
+ title: 'Hide tab',
64
+ enable: true,
65
+ condition: {
66
+ logic: '$and',
67
+ items: [{ path: 'status', operator: '$eq', value: 'visible' }],
68
+ },
69
+ actions: [
70
+ {
71
+ key: 'action-1',
72
+ name: 'linkageSetTabProps',
73
+ params: { value: 'hidden' },
74
+ },
75
+ ],
76
+ },
77
+ ],
78
+ };
79
+
80
+ describe('tab linkage rules', () => {
81
+ it('should append the tab linkage scene without changing existing scene values', () => {
82
+ const scenes = ActionScene as unknown as Record<string, number>;
83
+
84
+ expect(scenes.MENU_LINKAGE_RULES).toBe(7);
85
+ expect(scenes.TAB_LINKAGE_RULES).toBe(8);
86
+ expect(scenes.TAB_LINKAGE_RULES).toBe(Math.max(...Object.values(scenes).filter(Number.isInteger)));
87
+ });
88
+
89
+ it('should register tab linkage actions from the actions entry', () => {
90
+ expect((registeredActions as Record<string, unknown>).tabLinkageRules).toBe(tabLinkageRules);
91
+ expect((registeredActions as Record<string, unknown>).linkageSetTabProps).toBe(linkageSetTabProps);
92
+ expect(tabLinkageRules).toBeDefined();
93
+ expect(linkageSetTabProps).toBeDefined();
94
+ });
95
+
96
+ it('should expose tab state and runjs actions in ui schema', () => {
97
+ expect(tabLinkageRules).toBeDefined();
98
+ expect(linkageSetTabProps).toBeDefined();
99
+
100
+ const schema = tabLinkageRules?.uiSchema?.({
101
+ getActions: () =>
102
+ new Map([
103
+ ['linkageSetTabProps', linkageSetTabProps],
104
+ ['linkageRunjs', linkageActions.linkageRunjs],
105
+ ['linkageSetMenuItemProps', linkageActions.linkageSetMenuItemProps],
106
+ ]),
107
+ } as never) as Record<string, Record<string, Record<string, unknown>>> | undefined;
108
+
109
+ expect(schema?.value?.['x-component-props']?.supportedActions).toEqual(['linkageSetTabProps', 'linkageRunjs']);
110
+ expect(linkageSetTabProps?.scene).toBe((ActionScene as unknown as Record<string, number>).TAB_LINKAGE_RULES);
111
+ expect(linkageActions.linkageRunjs.scene).toContain(
112
+ (ActionScene as unknown as Record<string, number>).TAB_LINKAGE_RULES,
113
+ );
114
+ });
115
+
116
+ it('should hide the tab when the condition matches and restore it when the condition misses', async () => {
117
+ expect(tabLinkageRules).toBeDefined();
118
+ const runtime = createRuntime();
119
+
120
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
121
+ expect(runtime.model.hidden).toBe(true);
122
+
123
+ runtime.setMatched(false);
124
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
125
+ expect(runtime.model.hidden).toBe(false);
126
+ });
127
+
128
+ it('should restore the tab when linkage rules are cleared', async () => {
129
+ expect(tabLinkageRules).toBeDefined();
130
+ const runtime = createRuntime();
131
+
132
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
133
+ expect(runtime.model.hidden).toBe(true);
134
+
135
+ await tabLinkageRules?.handler(runtime.ctx as never, { value: [] });
136
+ expect(runtime.model.hidden).toBe(false);
137
+ });
138
+
139
+ it('should not restore stale tab props while applying or clearing visibility rules', async () => {
140
+ expect(tabLinkageRules).toBeDefined();
141
+ const runtime = createRuntime();
142
+ runtime.model.props = {
143
+ title: 'Original title',
144
+ icon: 'original-icon',
145
+ route: { options: { badge: 'original' } },
146
+ };
147
+
148
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
149
+ expect(runtime.model.hidden).toBe(true);
150
+
151
+ const updatedProps = {
152
+ title: 'Updated title',
153
+ icon: 'updated-icon',
154
+ route: { options: { badge: 'updated' } },
155
+ };
156
+ runtime.model.props = updatedProps;
157
+
158
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
159
+ expect(runtime.model.props).toMatchObject(updatedProps);
160
+ expect(runtime.model.hidden).toBe(true);
161
+
162
+ runtime.setMatched(false);
163
+ await tabLinkageRules?.handler(runtime.ctx as never, hideRule);
164
+ expect(runtime.model.props).toMatchObject(updatedProps);
165
+ expect(runtime.model.hidden).toBe(false);
166
+
167
+ await tabLinkageRules?.handler(runtime.ctx as never, { value: [] });
168
+ expect(runtime.model.props).toMatchObject(updatedProps);
169
+ expect(runtime.model.hidden).toBe(false);
170
+ });
171
+ });
@@ -43,10 +43,12 @@ export {
43
43
  linkageSetDetailsFieldProps,
44
44
  actionLinkageRules,
45
45
  menuLinkageRules,
46
+ tabLinkageRules,
46
47
  blockLinkageRules,
47
48
  linkageSetBlockProps,
48
49
  linkageSetActionProps,
49
50
  linkageSetMenuItemProps,
51
+ linkageSetTabProps,
50
52
  linkageSetFieldProps,
51
53
  subFormLinkageSetFieldProps,
52
54
  linkageAssignField,
@@ -731,6 +731,41 @@ export const linkageSetMenuItemProps = defineAction({
731
731
  },
732
732
  });
733
733
 
734
+ export const linkageSetTabProps = defineAction({
735
+ name: 'linkageSetTabProps',
736
+ title: tExpr('Set tab state'),
737
+ scene: ActionScene.TAB_LINKAGE_RULES,
738
+ sort: 100,
739
+ uiSchema: {
740
+ value: {
741
+ type: 'string',
742
+ 'x-component': (props) => {
743
+ const { value, onChange } = props;
744
+ // eslint-disable-next-line react-hooks/rules-of-hooks
745
+ const ctx = useFlowContext();
746
+ const t = ctx.model.translate.bind(ctx.model);
747
+
748
+ return (
749
+ <Select
750
+ value={value}
751
+ onChange={onChange}
752
+ placeholder={t('Please select state')}
753
+ style={{ width: '100%' }}
754
+ options={[
755
+ { label: t('Visible'), value: 'visible' },
756
+ { label: t('Hidden'), value: 'hidden' },
757
+ ]}
758
+ allowClear
759
+ />
760
+ );
761
+ },
762
+ },
763
+ },
764
+ handler(ctx, { value, setProps }) {
765
+ setProps(ctx.model, { hiddenModel: value === 'hidden' });
766
+ },
767
+ });
768
+
734
769
  export const linkageSetFieldProps = defineAction({
735
770
  name: 'linkageSetFieldProps',
736
771
  title: tExpr('Set field state'),
@@ -1339,6 +1374,7 @@ export const linkageRunjs = defineAction({
1339
1374
  ActionScene.FIELD_LINKAGE_RULES,
1340
1375
  ActionScene.ACTION_LINKAGE_RULES,
1341
1376
  ActionScene.MENU_LINKAGE_RULES,
1377
+ ActionScene.TAB_LINKAGE_RULES,
1342
1378
  ActionScene.DETAILS_FIELD_LINKAGE_RULES,
1343
1379
  ActionScene.SUB_FORM_FIELD_LINKAGE_RULES,
1344
1380
  ],
@@ -2169,17 +2205,30 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
2169
2205
  }
2170
2206
  : props;
2171
2207
 
2172
- // 存储原始值,用于恢复
2173
- if (!model.__originalProps) {
2174
- model.__originalProps = {
2175
- hiddenModel: model.hidden,
2176
- hiddenText: undefined,
2177
- disabled: undefined,
2178
- required: undefined,
2179
- hidden: undefined,
2180
- ...model.props,
2181
- };
2182
- }
2208
+ // 只记录联动实际控制的属性,避免之后恢复状态时把标题、路由等无关的新配置回滚到旧快照。
2209
+ const originalProps = model.__originalProps || (model.__originalProps = {});
2210
+ const rememberOriginalProp = (key: string, value: unknown) => {
2211
+ if (!Object.prototype.hasOwnProperty.call(originalProps, key)) {
2212
+ originalProps[key] = value;
2213
+ }
2214
+ };
2215
+ Object.keys(normalizedProps || {}).forEach((key) => {
2216
+ if (key === 'hiddenModel') {
2217
+ rememberOriginalProp(
2218
+ key,
2219
+ Object.prototype.hasOwnProperty.call(model.props || {}, key) ? model.props?.[key] : model.hidden,
2220
+ );
2221
+ return;
2222
+ }
2223
+
2224
+ rememberOriginalProp(key, model.props?.[key]);
2225
+ if (key === 'hiddenText') {
2226
+ rememberOriginalProp('title', model.props?.title);
2227
+ }
2228
+ if (key === 'required') {
2229
+ rememberOriginalProp('rules', model.props?.rules);
2230
+ }
2231
+ });
2183
2232
 
2184
2233
  // 临时存起来,遍历完所有规则后,再统一处理
2185
2234
  patchPropsByModel.set(model, {
@@ -2463,6 +2512,32 @@ export const menuLinkageRules = defineAction({
2463
2512
  },
2464
2513
  });
2465
2514
 
2515
+ export const tabLinkageRules = defineAction({
2516
+ name: 'tabLinkageRules',
2517
+ title: tExpr('Tab linkage rules'),
2518
+ uiMode: 'embed',
2519
+ uiSchema(ctx) {
2520
+ return {
2521
+ value: {
2522
+ type: 'array',
2523
+ 'x-component': LinkageRulesUI,
2524
+ 'x-component-props': {
2525
+ supportedActions: getSupportedActions(ctx, ActionScene.TAB_LINKAGE_RULES),
2526
+ title: tExpr('Tab linkage rules'),
2527
+ },
2528
+ },
2529
+ };
2530
+ },
2531
+ defaultParams: {
2532
+ value: [],
2533
+ },
2534
+ useRawParams: true,
2535
+ handler: async (ctx, params) => {
2536
+ const resolved = await resolveLinkageRulesParamsPreservingRunJsScripts(ctx, params);
2537
+ return commonLinkageRulesHandler(ctx, resolved);
2538
+ },
2539
+ });
2540
+
2466
2541
  export const fieldLinkageRules = defineAction({
2467
2542
  name: 'fieldLinkageRules',
2468
2543
  title: tExpr('Field linkage rules'),