@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.
- package/es/components/form/ScanInput/useCodeScanner.d.ts +2 -2
- package/es/flow/models/base/BlockGridModel.d.ts +3 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
- package/es/index.mjs +97 -94
- package/lib/index.js +89 -86
- package/package.json +7 -7
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
- package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
- package/src/flow/__tests__/FlowRoute.test.tsx +172 -11
- package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +115 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
- package/src/flow/actions/linkageRules.tsx +24 -12
- package/src/flow/components/BlockItemCard.tsx +2 -2
- package/src/flow/components/FlowRoute.tsx +72 -4
- package/src/flow/models/base/BlockGridModel.tsx +26 -0
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +27 -3
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +32 -4
- package/src/flow/models/blocks/js-block/JSBlock.tsx +1 -1
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
- 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 *
|
|
70
|
-
height: Math.floor(Math.min(height *
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
408
|
+
reportScanSuccess(result.decodedText);
|
|
267
409
|
} catch {
|
|
268
410
|
onScanFailure?.();
|
|
269
411
|
await startScanCamera(scanner);
|
|
270
412
|
}
|
|
271
413
|
},
|
|
272
|
-
[formatsToSupport, onScanFailure,
|
|
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
|
|
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={['/
|
|
468
|
+
<MemoryRouter initialEntries={['/nocobase/v2/apps/jhb20/admin/test-page/tab/tab-1?from=direct#dialog']}>
|
|
469
469
|
<Routes>
|
|
470
|
-
<Route path="/
|
|
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('
|
|
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
|
|
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
|
|
871
|
-
|
|
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
|
+
});
|