@nocobase/client-v2 2.1.28 → 2.1.30

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 (25) hide show
  1. package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
  2. package/es/flow/models/base/BlockGridModel.d.ts +3 -0
  3. package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
  4. package/es/index.mjs +97 -94
  5. package/lib/index.js +89 -86
  6. package/package.json +7 -7
  7. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
  8. package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
  9. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
  10. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
  11. package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
  12. package/src/flow/__tests__/FlowRoute.test.tsx +172 -11
  13. package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +115 -0
  14. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
  15. package/src/flow/actions/linkageRules.tsx +24 -12
  16. package/src/flow/components/BlockItemCard.tsx +2 -2
  17. package/src/flow/components/FlowRoute.tsx +72 -4
  18. package/src/flow/models/base/BlockGridModel.tsx +26 -0
  19. package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
  20. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
  21. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
  22. package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
  23. package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
  24. package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
  25. package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
11
11
  import jsQR from 'jsqr';
12
- import { useCallback, useEffect, useState } from 'react';
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
13
  import type { CodeFormatsToSupport } from './types';
14
14
 
15
15
  type ScannerSize = {
@@ -21,7 +21,6 @@ type UseCodeScannerOptions = {
21
21
  enabled: boolean;
22
22
  elementId: string;
23
23
  formatsToSupport?: CodeFormatsToSupport;
24
- scanBoxSize?: ScannerSize;
25
24
  onScannerSizeChanged?: (size: ScannerSize) => void;
26
25
  onScanSuccess: (text: string) => void;
27
26
  onScanFailure?: () => void;
@@ -38,7 +37,18 @@ type JsQRImageTransform = {
38
37
  threshold?: number;
39
38
  };
40
39
 
40
+ type FocusMediaTrackCapabilities = MediaTrackCapabilities & {
41
+ focusMode?: string[];
42
+ };
43
+
44
+ type FocusMediaTrackConstraintSet = MediaTrackConstraintSet & {
45
+ focusMode?: string;
46
+ };
47
+
41
48
  const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
49
+ const LIVE_QR_SCAN_INTERVAL = 120;
50
+ const LIVE_QR_SCAN_MAX_WIDTH = 960;
51
+ const LIVE_QR_SCAN_MAX_HEIGHT = 540;
42
52
  const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
43
53
  {},
44
54
  { contrast: 3, threshold: 105 },
@@ -66,8 +76,72 @@ export const DEFAULT_CODE_FORMATS: CodeFormatsToSupport = [
66
76
 
67
77
  export function getCodeScanBoxSize(width: number, height: number) {
68
78
  return {
69
- width: Math.floor(Math.min(width * 0.82, 520)),
70
- height: Math.floor(Math.min(height * 0.32, 240)),
79
+ width: Math.floor(Math.min((width * 90) / 100, 1152)),
80
+ height: Math.floor(Math.min((height * 70) / 100, 540)),
81
+ };
82
+ }
83
+
84
+ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
85
+ if (!video.videoWidth || !video.videoHeight || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
86
+ return;
87
+ }
88
+
89
+ const viewfinderWidth = video.clientWidth || video.videoWidth;
90
+ const viewfinderHeight = video.clientHeight || video.videoHeight;
91
+ const scanBoxSize = getCodeScanBoxSize(viewfinderWidth, viewfinderHeight);
92
+ const sourceWidth = Math.min(video.videoWidth, Math.floor(scanBoxSize.width * (video.videoWidth / viewfinderWidth)));
93
+ const sourceHeight = Math.min(
94
+ video.videoHeight,
95
+ Math.floor(scanBoxSize.height * (video.videoHeight / viewfinderHeight)),
96
+ );
97
+ const sourceX = Math.floor((video.videoWidth - sourceWidth) / 2);
98
+ const sourceY = Math.floor((video.videoHeight - sourceHeight) / 2);
99
+ const targetScale = Math.min(1, LIVE_QR_SCAN_MAX_WIDTH / sourceWidth, LIVE_QR_SCAN_MAX_HEIGHT / sourceHeight);
100
+ const targetWidth = Math.max(1, Math.floor(sourceWidth * targetScale));
101
+ const targetHeight = Math.max(1, Math.floor(sourceHeight * targetScale));
102
+ if (canvas.width !== targetWidth) {
103
+ canvas.width = targetWidth;
104
+ }
105
+ if (canvas.height !== targetHeight) {
106
+ canvas.height = targetHeight;
107
+ }
108
+ const context = canvas.getContext('2d', { willReadFrequently: true });
109
+ if (!context) {
110
+ return;
111
+ }
112
+
113
+ context.drawImage(video, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, targetWidth, targetHeight);
114
+ const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
115
+ return jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })?.data;
116
+ }
117
+
118
+ function startLiveQrScan(elementId: string, onScanSuccess: (text: string) => void) {
119
+ const canvas = document.createElement('canvas');
120
+ let timer: number | undefined;
121
+ let stopped = false;
122
+
123
+ const scan = () => {
124
+ if (stopped) {
125
+ return;
126
+ }
127
+ const video = document.getElementById(elementId)?.querySelector('video');
128
+ if (video) {
129
+ const decodedText = scanQrVideoFrame(video, canvas);
130
+ if (decodedText) {
131
+ stopped = true;
132
+ onScanSuccess(decodedText);
133
+ return;
134
+ }
135
+ }
136
+ timer = window.setTimeout(scan, LIVE_QR_SCAN_INTERVAL);
137
+ };
138
+
139
+ timer = window.setTimeout(scan, 0);
140
+ return () => {
141
+ stopped = true;
142
+ if (timer !== undefined) {
143
+ window.clearTimeout(timer);
144
+ }
71
145
  };
72
146
  }
73
147
 
@@ -92,6 +166,19 @@ async function stopScanner(scanner?: Html5Qrcode, options: { clear?: boolean } =
92
166
  }
93
167
  }
94
168
 
169
+ async function enableContinuousFocus(scanner: Html5Qrcode) {
170
+ try {
171
+ const capabilities = scanner.getRunningTrackCapabilities() as FocusMediaTrackCapabilities;
172
+ if (!capabilities.focusMode?.includes('continuous')) {
173
+ return;
174
+ }
175
+ const focusConstraints: FocusMediaTrackConstraintSet = { focusMode: 'continuous' };
176
+ await scanner.applyVideoConstraints({ advanced: [focusConstraints] });
177
+ } catch {
178
+ // Some browsers expose incomplete camera capability APIs. Scanning should continue without explicit focus control.
179
+ }
180
+ }
181
+
95
182
  function isSafariBrowser() {
96
183
  const { userAgent, vendor } = navigator;
97
184
  return /Apple/i.test(vendor) && /Safari/i.test(userAgent) && !/CriOS|FxiOS|EdgiOS|Chrome/i.test(userAgent);
@@ -216,40 +303,95 @@ export function useCodeScanner({
216
303
  enabled,
217
304
  elementId,
218
305
  formatsToSupport,
219
- scanBoxSize,
220
306
  onScannerSizeChanged,
221
307
  onScanSuccess,
222
308
  onScanFailure,
223
309
  onCameraStartFailure,
224
310
  }: UseCodeScannerOptions) {
225
311
  const [scanner, setScanner] = useState<Html5Qrcode>();
312
+ const liveQrScanStopRef = useRef<() => void>();
313
+ const scanSucceededRef = useRef(false);
314
+ const scanSessionRef = useRef(0);
315
+
316
+ const stopLiveQrScan = useCallback(() => {
317
+ liveQrScanStopRef.current?.();
318
+ liveQrScanStopRef.current = undefined;
319
+ }, []);
320
+
321
+ const cancelActiveScan = useCallback(() => {
322
+ scanSessionRef.current += 1;
323
+ stopLiveQrScan();
324
+ }, [stopLiveQrScan]);
325
+
326
+ const reportScanSuccess = useCallback(
327
+ (text: string) => {
328
+ if (scanSucceededRef.current) {
329
+ return;
330
+ }
331
+ scanSucceededRef.current = true;
332
+ stopLiveQrScan();
333
+ onScanSuccess(text);
334
+ },
335
+ [onScanSuccess, stopLiveQrScan],
336
+ );
226
337
 
227
338
  const startScanCamera = useCallback(
228
339
  async (scannerInstance: Html5Qrcode) => {
229
- await scannerInstance.start(
230
- { facingMode: 'environment' },
231
- {
232
- fps: 10,
233
- qrbox(width, height) {
234
- onScannerSizeChanged?.({ width, height });
235
- return clampCodeScanBoxSize(scanBoxSize ?? getCodeScanBoxSize(width, height), width, height);
340
+ cancelActiveScan();
341
+ const scanSession = scanSessionRef.current;
342
+ scanSucceededRef.current = false;
343
+ try {
344
+ await scannerInstance.start(
345
+ { facingMode: 'environment' },
346
+ {
347
+ fps: 8,
348
+ disableFlip: false,
349
+ videoConstraints: {
350
+ facingMode: { ideal: 'environment' },
351
+ width: { ideal: 1920 },
352
+ height: { ideal: 1080 },
353
+ frameRate: { ideal: 30 },
354
+ },
355
+ qrbox(width, height) {
356
+ onScannerSizeChanged?.({ width, height });
357
+ return clampCodeScanBoxSize(getCodeScanBoxSize(width, height), width, height);
358
+ },
359
+ },
360
+ (decodedText) => {
361
+ reportScanSuccess(decodedText);
236
362
  },
237
- },
238
- (decodedText) => {
239
- onScanSuccess(decodedText);
240
- },
241
- undefined,
242
- );
363
+ undefined,
364
+ );
365
+ } catch (error) {
366
+ if (scanSession !== scanSessionRef.current) {
367
+ return;
368
+ }
369
+ throw error;
370
+ }
371
+ if (scanSession !== scanSessionRef.current) {
372
+ try {
373
+ await stopScanner(scannerInstance, { clear: true });
374
+ } catch {
375
+ // The scanner may already have been cleared by the canceled session cleanup.
376
+ }
377
+ return;
378
+ }
379
+ if (shouldScanQrWithJsQR(formatsToSupport)) {
380
+ liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess);
381
+ }
382
+ await enableContinuousFocus(scannerInstance);
243
383
  },
244
- [onScanSuccess, onScannerSizeChanged, scanBoxSize],
384
+ [cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess],
245
385
  );
246
386
 
247
387
  const startScanFile = useCallback(
248
388
  async (file: File) => {
389
+ cancelActiveScan();
390
+ scanSucceededRef.current = false;
249
391
  if (isSafariBrowser() && shouldScanQrWithJsQR(formatsToSupport)) {
250
392
  try {
251
393
  const decodedText = await scanFileWithJsQR(file, formatsToSupport);
252
- onScanSuccess(decodedText);
394
+ reportScanSuccess(decodedText);
253
395
  return;
254
396
  } catch {
255
397
  // Fall through to html5-qrcode so barcode uploads still work in Safari.
@@ -263,13 +405,13 @@ export function useCodeScanner({
263
405
  await stopScanner(scanner);
264
406
  try {
265
407
  const result = await scanner.scanFileV2(file, false);
266
- onScanSuccess(result.decodedText);
408
+ reportScanSuccess(result.decodedText);
267
409
  } catch {
268
410
  onScanFailure?.();
269
411
  await startScanCamera(scanner);
270
412
  }
271
413
  },
272
- [formatsToSupport, onScanFailure, onScanSuccess, scanner, startScanCamera],
414
+ [cancelActiveScan, formatsToSupport, onScanFailure, reportScanSuccess, scanner, startScanCamera],
273
415
  );
274
416
 
275
417
  useEffect(() => {
@@ -291,9 +433,10 @@ export function useCodeScanner({
291
433
  });
292
434
 
293
435
  return () => {
436
+ cancelActiveScan();
294
437
  stopScanner(scannerInstance, { clear: true }).catch(() => undefined);
295
438
  };
296
- }, [elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
439
+ }, [cancelActiveScan, elementId, enabled, formatsToSupport, onCameraStartFailure, onScanFailure, startScanCamera]);
297
440
 
298
441
  return {
299
442
  startScanFile,
@@ -421,14 +421,14 @@ describe('FlowRoute', () => {
421
421
  });
422
422
  });
423
423
 
424
- it('should show 404 when current route is a legacy page in v2 runtime', async () => {
424
+ it('should explain how to open a legacy page outside the v2 runtime', async () => {
425
425
  const originalLocation = window.location;
426
426
  const replace = vi.fn();
427
427
  Object.defineProperty(window, 'location', {
428
428
  configurable: true,
429
429
  value: {
430
430
  ...originalLocation,
431
- pathname: '/v2/admin/test-page/tab/tab-1',
431
+ pathname: '/nocobase/v2/apps/jhb20/admin/test-page/tab/tab-1',
432
432
  search: '?from=direct',
433
433
  hash: '#dialog',
434
434
  replace,
@@ -447,9 +447,9 @@ describe('FlowRoute', () => {
447
447
  });
448
448
  engine.context.defineProperty('app', {
449
449
  value: {
450
- getPublicPath: () => '/v2/',
450
+ getPublicPath: () => '/nocobase/v2/',
451
451
  router: {
452
- getBasename: () => '/v2',
452
+ getBasename: () => '/nocobase/v2',
453
453
  },
454
454
  },
455
455
  });
@@ -465,15 +465,25 @@ describe('FlowRoute', () => {
465
465
 
466
466
  render(
467
467
  <FlowEngineProvider engine={engine}>
468
- <MemoryRouter initialEntries={['/flow/test-page']}>
468
+ <MemoryRouter initialEntries={['/nocobase/v2/apps/jhb20/admin/test-page/tab/tab-1?from=direct#dialog']}>
469
469
  <Routes>
470
- <Route path="/flow/:name" element={<FlowRoute />} />
470
+ <Route path="/nocobase/v2/apps/jhb20/admin/:name/*" element={<FlowRoute />} />
471
471
  </Routes>
472
472
  </MemoryRouter>
473
473
  </FlowEngineProvider>,
474
474
  );
475
475
 
476
- expect(await screen.findByText('404')).toBeInTheDocument();
476
+ expect(await screen.findByText('This page is not supported in the /v2/ branch')).toBeInTheDocument();
477
+ expect(
478
+ screen.getByText(
479
+ 'The /v2/ branch only supports new pages. This page is a legacy page. Please open it from the original entry.',
480
+ ),
481
+ ).toBeInTheDocument();
482
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
483
+ expect(screen.getByRole('link', { name: 'Open from the original entry' })).toHaveAttribute(
484
+ 'href',
485
+ '/nocobase/apps/jhb20/admin/test-page/tab/tab-1?from=direct#dialog',
486
+ );
477
487
  expect(replace).not.toHaveBeenCalled();
478
488
  expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
479
489
  expect(adminLayoutModel.updateRoutePage).not.toHaveBeenCalled();
@@ -546,6 +556,113 @@ describe('FlowRoute', () => {
546
556
  }
547
557
  });
548
558
 
559
+ it('should render 404 for missing FlowModel in notFound mode without routeRepository', async () => {
560
+ const engine = new FlowEngine();
561
+ engine.context.defineProperty('app', {
562
+ value: {
563
+ getPublicPath: () => '/v2/',
564
+ router: {
565
+ getBasename: () => '/v2',
566
+ },
567
+ },
568
+ });
569
+
570
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
571
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
572
+ {
573
+ registerRoutePage: vi.fn(),
574
+ updateRoutePage: vi.fn(),
575
+ unregisterRoutePage: vi.fn(),
576
+ },
577
+ );
578
+
579
+ render(
580
+ <FlowEngineProvider engine={engine}>
581
+ <MemoryRouter initialEntries={['/embed/missing-page']}>
582
+ <Routes>
583
+ <Route path="/embed/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
584
+ </Routes>
585
+ </MemoryRouter>
586
+ </FlowEngineProvider>,
587
+ );
588
+
589
+ expect(await screen.findByText('404')).toBeInTheDocument();
590
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
591
+ });
592
+
593
+ it('should bridge by default when routeRepository does not exist', async () => {
594
+ const engine = new FlowEngine();
595
+ engine.context.defineProperty('app', {
596
+ value: {
597
+ getPublicPath: () => '/v2/',
598
+ router: {
599
+ getBasename: () => '/v2',
600
+ },
601
+ },
602
+ });
603
+
604
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
605
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
606
+ {
607
+ registerRoutePage: vi.fn(),
608
+ updateRoutePage: vi.fn(),
609
+ unregisterRoutePage: vi.fn(),
610
+ },
611
+ );
612
+
613
+ render(
614
+ <FlowEngineProvider engine={engine}>
615
+ <MemoryRouter initialEntries={['/flow/missing-page']}>
616
+ <Routes>
617
+ <Route path="/flow/:name" element={<FlowRoute />} />
618
+ </Routes>
619
+ </MemoryRouter>
620
+ </FlowEngineProvider>,
621
+ );
622
+
623
+ await waitFor(() => {
624
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('missing-page', expect.any(Object));
625
+ });
626
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
627
+ });
628
+
629
+ it('should bridge existing FlowModel in notFound mode without routeRepository', async () => {
630
+ const engine = new FlowEngine();
631
+ engine.createModel({ uid: 'test-page', use: 'FlowModel' });
632
+ engine.context.defineProperty('app', {
633
+ value: {
634
+ getPublicPath: () => '/v2/',
635
+ router: {
636
+ getBasename: () => '/v2',
637
+ },
638
+ },
639
+ });
640
+
641
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
642
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
643
+ {
644
+ registerRoutePage: vi.fn(),
645
+ updateRoutePage: vi.fn(),
646
+ unregisterRoutePage: vi.fn(),
647
+ },
648
+ );
649
+
650
+ render(
651
+ <FlowEngineProvider engine={engine}>
652
+ <MemoryRouter initialEntries={['/embed/test-page']}>
653
+ <Routes>
654
+ <Route path="/embed/:name" element={<FlowRoute legacyPageBehavior="notFound" />} />
655
+ </Routes>
656
+ </MemoryRouter>
657
+ </FlowEngineProvider>,
658
+ );
659
+
660
+ await waitFor(() => {
661
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('test-page', expect.any(Object));
662
+ });
663
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
664
+ });
665
+
549
666
  it('should bridge existing FlowModel when behavior is notFound and routeRepository has no route', async () => {
550
667
  const engine = new FlowEngine();
551
668
  engine.setModelRepository({
@@ -818,7 +935,7 @@ describe('FlowRoute', () => {
818
935
  }
819
936
  });
820
937
 
821
- it('should not redirect when route does not exist', async () => {
938
+ it('should render 404 when route and FlowModel do not exist', async () => {
822
939
  const originalLocation = window.location;
823
940
  const replace = vi.fn();
824
941
  Object.defineProperty(window, 'location', {
@@ -867,9 +984,8 @@ describe('FlowRoute', () => {
867
984
  </FlowEngineProvider>,
868
985
  );
869
986
 
870
- await waitFor(() => {
871
- expect(adminLayoutModel.registerRoutePage).toHaveBeenCalled();
872
- });
987
+ expect(await screen.findByText('404')).toBeInTheDocument();
988
+ expect(adminLayoutModel.registerRoutePage).not.toHaveBeenCalled();
873
989
  expect(replace).not.toHaveBeenCalled();
874
990
  } finally {
875
991
  Object.defineProperty(window, 'location', {
@@ -879,6 +995,51 @@ describe('FlowRoute', () => {
879
995
  }
880
996
  });
881
997
 
998
+ it('should bridge existing FlowModel when route metadata does not exist', async () => {
999
+ const engine = new FlowEngine();
1000
+ engine.createModel({ uid: 'test-page', use: 'FlowModel' });
1001
+ engine.context.defineProperty('routeRepository', {
1002
+ value: {
1003
+ refreshAccessible: hookState.refresh,
1004
+ isAccessibleLoaded: () => true,
1005
+ ensureAccessibleLoaded: vi.fn().mockResolvedValue([]),
1006
+ getRouteBySchemaUid: vi.fn(() => undefined),
1007
+ },
1008
+ });
1009
+ engine.context.defineProperty('app', {
1010
+ value: {
1011
+ getPublicPath: () => '/v2/',
1012
+ router: {
1013
+ getBasename: () => '/v2',
1014
+ },
1015
+ },
1016
+ });
1017
+
1018
+ const adminLayoutModel: MockAdminLayoutModel = Object.assign(
1019
+ engine.createModel({ uid: 'admin-layout-model', use: 'FlowModel' }),
1020
+ {
1021
+ registerRoutePage: vi.fn(),
1022
+ updateRoutePage: vi.fn(),
1023
+ unregisterRoutePage: vi.fn(),
1024
+ },
1025
+ );
1026
+
1027
+ render(
1028
+ <FlowEngineProvider engine={engine}>
1029
+ <MemoryRouter initialEntries={['/flow/test-page']}>
1030
+ <Routes>
1031
+ <Route path="/flow/:name" element={<FlowRoute />} />
1032
+ </Routes>
1033
+ </MemoryRouter>
1034
+ </FlowEngineProvider>,
1035
+ );
1036
+
1037
+ await waitFor(() => {
1038
+ expect(adminLayoutModel.registerRoutePage).toHaveBeenCalledWith('test-page', expect.any(Object));
1039
+ });
1040
+ expect(screen.queryByText('404')).not.toBeInTheDocument();
1041
+ });
1042
+
882
1043
  it('should keep sub app page inside spa when basename does not include /v2/', async () => {
883
1044
  const originalLocation = window.location;
884
1045
  const replace = vi.fn();
@@ -0,0 +1,115 @@
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 { FlowEngine } from '@nocobase/flow-engine';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { ActionModel as ConfiguredActionModel } from '../../models/base/ActionModel';
13
+ import { ActionModel } from '../../models/base/ActionModelCore';
14
+ import { actionLinkageRules, linkageSetActionProps } from '../linkageRules';
15
+
16
+ class TestActionModel extends ActionModel {}
17
+
18
+ describe('action linkage rules on action forks', () => {
19
+ it('does not snapshot unrelated master props when disabling an action', async () => {
20
+ const engine = new FlowEngine();
21
+ engine.registerModels({ TestActionModel });
22
+ const master = engine.createModel<TestActionModel>({
23
+ use: 'TestActionModel',
24
+ props: { title: 'Edit' },
25
+ });
26
+ const fork = master.createFork({ className: 'row-action' });
27
+
28
+ const ctx = {
29
+ flowKey: 'buttonSettings',
30
+ model: fork,
31
+ app: {
32
+ jsonLogic: {
33
+ apply: vi.fn(() => true),
34
+ },
35
+ },
36
+ t: (value: string) => value,
37
+ resolveJsonTemplate: vi.fn(async (value: unknown) => value),
38
+ getAction: (name: string) => {
39
+ if (name !== 'linkageSetActionProps') return undefined;
40
+ return {
41
+ handler: async (_ctx: unknown, params: { setProps: Function }) => {
42
+ params.setProps(fork, { disabled: true });
43
+ },
44
+ };
45
+ },
46
+ } as never;
47
+
48
+ await actionLinkageRules.handler(ctx, {
49
+ value: [
50
+ {
51
+ key: 'disable-edit',
52
+ enable: true,
53
+ condition: { logic: '$and', items: [] },
54
+ actions: [{ name: 'linkageSetActionProps', params: { value: 'disabled' } }],
55
+ },
56
+ ],
57
+ });
58
+
59
+ expect(fork.localProps.title).toBeUndefined();
60
+ expect(fork.__originalProps.title).toBeUndefined();
61
+ expect(fork.localProps.disabled).toBe(true);
62
+
63
+ master.setProps('title', 'Updated');
64
+ await actionLinkageRules.handler(ctx, {
65
+ value: [
66
+ {
67
+ key: 'disable-edit',
68
+ enable: true,
69
+ condition: { logic: '$and', items: [] },
70
+ actions: [{ name: 'linkageSetActionProps', params: { value: 'disabled' } }],
71
+ },
72
+ ],
73
+ });
74
+
75
+ expect(fork.getProps().title).toBe('Updated');
76
+ expect(fork.serialize().props.title).toBe('Updated');
77
+
78
+ const refreshedFork = master.createFork({ className: 'row-action' });
79
+ expect(refreshedFork.getProps().title).toBe('Updated');
80
+ });
81
+
82
+ it('updates a disabled row action title through the real beforeRender flow', async () => {
83
+ const engine = new FlowEngine();
84
+ engine.registerModels({ ConfiguredActionModel });
85
+ engine.registerActions({ actionLinkageRules, linkageSetActionProps });
86
+ const master = engine.createModel<ConfiguredActionModel>({
87
+ use: 'ConfiguredActionModel',
88
+ props: { title: 'Edit' },
89
+ stepParams: {
90
+ buttonSettings: {
91
+ general: { title: 'Edit' },
92
+ linkageRules: {
93
+ value: [
94
+ {
95
+ key: 'disable-edit',
96
+ enable: true,
97
+ condition: { logic: '$and', items: [] },
98
+ actions: [{ name: 'linkageSetActionProps', params: { value: 'disabled' } }],
99
+ },
100
+ ],
101
+ },
102
+ },
103
+ },
104
+ });
105
+ const fork = master.createFork({ className: 'row-action' });
106
+
107
+ await fork.dispatchEvent('beforeRender', undefined, { useCache: false });
108
+ expect(fork.getProps()).toMatchObject({ title: 'Edit', disabled: true });
109
+
110
+ master.setStepParams('buttonSettings', 'general', { title: 'Updated' });
111
+ await new Promise((resolve) => setTimeout(resolve, 0));
112
+
113
+ expect(fork.getProps()).toMatchObject({ title: 'Updated', disabled: true });
114
+ });
115
+ });