@nocobase/client-v2 2.3.0-beta.7 → 2.3.0-beta.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.
- package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/index.mjs +19 -19
- package/lib/index.js +38 -38
- package/package.json +9 -8
- package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
- package/src/collection-manager/field-configure.ts +2 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
- package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
import { Html5Qrcode, Html5QrcodeScannerState, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
|
11
11
|
import jsQR from 'jsqr';
|
|
12
12
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
13
|
+
import type { RefObject } from 'react';
|
|
13
14
|
import type { CodeFormatsToSupport } from './types';
|
|
15
|
+
import { decodeQrCodeWithZxingWasm } from './zxingWasmDecoder';
|
|
14
16
|
|
|
15
17
|
type ScannerSize = {
|
|
16
18
|
width: number;
|
|
@@ -22,6 +24,7 @@ type UseCodeScannerOptions = {
|
|
|
22
24
|
elementId: string;
|
|
23
25
|
formatsToSupport?: CodeFormatsToSupport;
|
|
24
26
|
onScannerSizeChanged?: (size: ScannerSize) => void;
|
|
27
|
+
scanViewportRef?: RefObject<HTMLElement | null>;
|
|
25
28
|
onScanSuccess: (text: string) => void;
|
|
26
29
|
onScanFailure?: () => void;
|
|
27
30
|
onCameraStartFailure?: (error: unknown) => void;
|
|
@@ -37,6 +40,12 @@ type JsQRImageTransform = {
|
|
|
37
40
|
threshold?: number;
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
type QrFrameCaptureLimits = {
|
|
44
|
+
maxHeight: number;
|
|
45
|
+
maxPixels: number;
|
|
46
|
+
maxWidth: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
40
49
|
type FocusMediaTrackCapabilities = MediaTrackCapabilities & {
|
|
41
50
|
focusMode?: string[];
|
|
42
51
|
};
|
|
@@ -47,8 +56,15 @@ type FocusMediaTrackConstraintSet = MediaTrackConstraintSet & {
|
|
|
47
56
|
|
|
48
57
|
const QR_SCAN_IMAGE_SIZES = [3200, 2400, 1600, 1000];
|
|
49
58
|
const LIVE_QR_SCAN_INTERVAL = 120;
|
|
59
|
+
const IOS_ZXING_SCAN_INTERVAL = 200;
|
|
50
60
|
const LIVE_QR_SCAN_MAX_WIDTH = 960;
|
|
51
61
|
const LIVE_QR_SCAN_MAX_HEIGHT = 540;
|
|
62
|
+
const LIVE_QR_SCAN_MAX_PIXELS = LIVE_QR_SCAN_MAX_WIDTH * 420;
|
|
63
|
+
const IOS_ZXING_SCAN_LIMITS: QrFrameCaptureLimits = {
|
|
64
|
+
maxHeight: 720,
|
|
65
|
+
maxPixels: 720 * 960,
|
|
66
|
+
maxWidth: 1280,
|
|
67
|
+
};
|
|
52
68
|
const QR_SCAN_IMAGE_TRANSFORMS: JsQRImageTransform[] = [
|
|
53
69
|
{},
|
|
54
70
|
{ contrast: 3, threshold: 105 },
|
|
@@ -81,7 +97,49 @@ export function getCodeScanBoxSize(width: number, height: number) {
|
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
export function
|
|
100
|
+
export function isIOSBrowser() {
|
|
101
|
+
return (
|
|
102
|
+
/iPad|iPhone|iPod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getVisibleVideoFrameRegion(video: HTMLVideoElement, scanViewport: Element) {
|
|
107
|
+
const rect = video.getBoundingClientRect();
|
|
108
|
+
const viewportRect = scanViewport.getBoundingClientRect();
|
|
109
|
+
const visibleLeft = Math.max(viewportRect.left, rect.left);
|
|
110
|
+
const visibleTop = Math.max(viewportRect.top, rect.top);
|
|
111
|
+
const visibleRight = Math.min(viewportRect.right, rect.right);
|
|
112
|
+
const visibleBottom = Math.min(viewportRect.bottom, rect.bottom);
|
|
113
|
+
if (!rect.width || !rect.height || visibleRight <= visibleLeft || visibleBottom <= visibleTop) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sourceX = Math.max(0, Math.floor(((visibleLeft - rect.left) / rect.width) * video.videoWidth));
|
|
118
|
+
const sourceY = Math.max(0, Math.floor(((visibleTop - rect.top) / rect.height) * video.videoHeight));
|
|
119
|
+
return {
|
|
120
|
+
x: sourceX,
|
|
121
|
+
y: sourceY,
|
|
122
|
+
width: Math.min(
|
|
123
|
+
video.videoWidth - sourceX,
|
|
124
|
+
Math.ceil(((visibleRight - visibleLeft) / rect.width) * video.videoWidth),
|
|
125
|
+
),
|
|
126
|
+
height: Math.min(
|
|
127
|
+
video.videoHeight - sourceY,
|
|
128
|
+
Math.ceil(((visibleBottom - visibleTop) / rect.height) * video.videoHeight),
|
|
129
|
+
),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function getQrVideoFrameImageData(
|
|
134
|
+
video: HTMLVideoElement,
|
|
135
|
+
canvas: HTMLCanvasElement,
|
|
136
|
+
scanViewport?: Element,
|
|
137
|
+
limits: QrFrameCaptureLimits = {
|
|
138
|
+
maxHeight: LIVE_QR_SCAN_MAX_HEIGHT,
|
|
139
|
+
maxPixels: LIVE_QR_SCAN_MAX_PIXELS,
|
|
140
|
+
maxWidth: LIVE_QR_SCAN_MAX_WIDTH,
|
|
141
|
+
},
|
|
142
|
+
) {
|
|
85
143
|
if (!video.videoWidth || !video.videoHeight || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
86
144
|
return;
|
|
87
145
|
}
|
|
@@ -89,16 +147,27 @@ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElem
|
|
|
89
147
|
const viewfinderWidth = video.clientWidth || video.videoWidth;
|
|
90
148
|
const viewfinderHeight = video.clientHeight || video.videoHeight;
|
|
91
149
|
const scanBoxSize = getCodeScanBoxSize(viewfinderWidth, viewfinderHeight);
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
video.videoHeight,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
const scanBoxRegion = {
|
|
151
|
+
width: Math.min(video.videoWidth, Math.floor(scanBoxSize.width * (video.videoWidth / viewfinderWidth))),
|
|
152
|
+
height: Math.min(video.videoHeight, Math.floor(scanBoxSize.height * (video.videoHeight / viewfinderHeight))),
|
|
153
|
+
};
|
|
154
|
+
const sourceRegion = scanViewport
|
|
155
|
+
? getVisibleVideoFrameRegion(video, scanViewport)
|
|
156
|
+
: {
|
|
157
|
+
x: Math.floor((video.videoWidth - scanBoxRegion.width) / 2),
|
|
158
|
+
y: Math.floor((video.videoHeight - scanBoxRegion.height) / 2),
|
|
159
|
+
...scanBoxRegion,
|
|
160
|
+
};
|
|
161
|
+
if (!sourceRegion) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const maxWidth = scanViewport && sourceRegion.height > sourceRegion.width ? limits.maxHeight : limits.maxWidth;
|
|
166
|
+
const maxHeight = scanViewport && sourceRegion.height > sourceRegion.width ? limits.maxWidth : limits.maxHeight;
|
|
167
|
+
const pixelScale = scanViewport ? Math.sqrt(limits.maxPixels / (sourceRegion.width * sourceRegion.height)) : 1;
|
|
168
|
+
const targetScale = Math.min(1, maxWidth / sourceRegion.width, maxHeight / sourceRegion.height, pixelScale);
|
|
169
|
+
const targetWidth = Math.max(1, Math.floor(sourceRegion.width * targetScale));
|
|
170
|
+
const targetHeight = Math.max(1, Math.floor(sourceRegion.height * targetScale));
|
|
102
171
|
if (canvas.width !== targetWidth) {
|
|
103
172
|
canvas.width = targetWidth;
|
|
104
173
|
}
|
|
@@ -110,30 +179,75 @@ export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElem
|
|
|
110
179
|
return;
|
|
111
180
|
}
|
|
112
181
|
|
|
113
|
-
context.drawImage(
|
|
114
|
-
|
|
182
|
+
context.drawImage(
|
|
183
|
+
video,
|
|
184
|
+
sourceRegion.x,
|
|
185
|
+
sourceRegion.y,
|
|
186
|
+
sourceRegion.width,
|
|
187
|
+
sourceRegion.height,
|
|
188
|
+
0,
|
|
189
|
+
0,
|
|
190
|
+
targetWidth,
|
|
191
|
+
targetHeight,
|
|
192
|
+
);
|
|
193
|
+
return context.getImageData(0, 0, targetWidth, targetHeight);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement, scanViewport?: Element) {
|
|
197
|
+
const imageData = getQrVideoFrameImageData(video, canvas, scanViewport);
|
|
198
|
+
if (!imageData) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
115
201
|
return jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })?.data;
|
|
116
202
|
}
|
|
117
203
|
|
|
118
|
-
|
|
204
|
+
export async function scanQrVideoFrameWithZxingWasm(
|
|
205
|
+
video: HTMLVideoElement,
|
|
206
|
+
canvas: HTMLCanvasElement,
|
|
207
|
+
scanViewport: Element,
|
|
208
|
+
) {
|
|
209
|
+
const imageData = getQrVideoFrameImageData(video, canvas, scanViewport, IOS_ZXING_SCAN_LIMITS);
|
|
210
|
+
if (!imageData) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
return decodeQrCodeWithZxingWasm(imageData);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function startLiveQrScan(
|
|
217
|
+
elementId: string,
|
|
218
|
+
onScanSuccess: (text: string) => void,
|
|
219
|
+
scanViewportRef?: RefObject<HTMLElement | null>,
|
|
220
|
+
) {
|
|
119
221
|
const canvas = document.createElement('canvas');
|
|
222
|
+
const useVisiblePreview = isIOSBrowser();
|
|
120
223
|
let timer: number | undefined;
|
|
121
224
|
let stopped = false;
|
|
122
225
|
|
|
123
|
-
const scan = () => {
|
|
226
|
+
const scan = async () => {
|
|
124
227
|
if (stopped) {
|
|
125
228
|
return;
|
|
126
229
|
}
|
|
127
230
|
const video = document.getElementById(elementId)?.querySelector('video');
|
|
231
|
+
const scanViewport = useVisiblePreview ? scanViewportRef?.current : undefined;
|
|
128
232
|
if (video) {
|
|
129
|
-
|
|
130
|
-
|
|
233
|
+
let decodedText: string | undefined;
|
|
234
|
+
try {
|
|
235
|
+
decodedText =
|
|
236
|
+
useVisiblePreview && scanViewport
|
|
237
|
+
? await scanQrVideoFrameWithZxingWasm(video, canvas, scanViewport)
|
|
238
|
+
: scanQrVideoFrame(video, canvas);
|
|
239
|
+
} catch {
|
|
240
|
+
// Keep the existing scanners active when the optional WASM decoder cannot load or decode a frame.
|
|
241
|
+
}
|
|
242
|
+
if (decodedText && !stopped) {
|
|
131
243
|
stopped = true;
|
|
132
244
|
onScanSuccess(decodedText);
|
|
133
245
|
return;
|
|
134
246
|
}
|
|
135
247
|
}
|
|
136
|
-
|
|
248
|
+
if (!stopped) {
|
|
249
|
+
timer = window.setTimeout(scan, useVisiblePreview ? IOS_ZXING_SCAN_INTERVAL : LIVE_QR_SCAN_INTERVAL);
|
|
250
|
+
}
|
|
137
251
|
};
|
|
138
252
|
|
|
139
253
|
timer = window.setTimeout(scan, 0);
|
|
@@ -304,6 +418,7 @@ export function useCodeScanner({
|
|
|
304
418
|
elementId,
|
|
305
419
|
formatsToSupport,
|
|
306
420
|
onScannerSizeChanged,
|
|
421
|
+
scanViewportRef,
|
|
307
422
|
onScanSuccess,
|
|
308
423
|
onScanFailure,
|
|
309
424
|
onCameraStartFailure,
|
|
@@ -377,11 +492,11 @@ export function useCodeScanner({
|
|
|
377
492
|
return;
|
|
378
493
|
}
|
|
379
494
|
if (shouldScanQrWithJsQR(formatsToSupport)) {
|
|
380
|
-
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess);
|
|
495
|
+
liveQrScanStopRef.current = startLiveQrScan(elementId, reportScanSuccess, scanViewportRef);
|
|
381
496
|
}
|
|
382
497
|
await enableContinuousFocus(scannerInstance);
|
|
383
498
|
},
|
|
384
|
-
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess],
|
|
499
|
+
[cancelActiveScan, elementId, formatsToSupport, onScannerSizeChanged, reportScanSuccess, scanViewportRef],
|
|
385
500
|
);
|
|
386
501
|
|
|
387
502
|
const startScanFile = useCallback(
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
let decoderPromise: Promise<typeof import('zxing-wasm/reader')> | undefined;
|
|
11
|
+
|
|
12
|
+
async function loadDecoder() {
|
|
13
|
+
if (!decoderPromise) {
|
|
14
|
+
decoderPromise = Promise.all([import('zxing-wasm/reader'), import('zxing-wasm/reader/zxing_reader.wasm')])
|
|
15
|
+
.then(async ([decoder, { default: wasmUrl }]) => {
|
|
16
|
+
try {
|
|
17
|
+
await decoder.prepareZXingModule({
|
|
18
|
+
fireImmediately: true,
|
|
19
|
+
overrides: { locateFile: () => wasmUrl },
|
|
20
|
+
});
|
|
21
|
+
} catch (error) {
|
|
22
|
+
decoder.purgeZXingModule();
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
return decoder;
|
|
26
|
+
})
|
|
27
|
+
.catch((error: unknown) => {
|
|
28
|
+
decoderPromise = undefined;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return decoderPromise;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function decodeQrCodeWithZxingWasm(imageData: ImageData) {
|
|
36
|
+
const decoder = await loadDecoder();
|
|
37
|
+
const results = await decoder.readBarcodes(imageData, {
|
|
38
|
+
binarizer: 'GlobalHistogram',
|
|
39
|
+
downscaleThreshold: 300,
|
|
40
|
+
formats: ['QRCode'],
|
|
41
|
+
maxNumberOfSymbols: 1,
|
|
42
|
+
tryDenoise: true,
|
|
43
|
+
tryDownscale: true,
|
|
44
|
+
tryHarder: true,
|
|
45
|
+
tryInvert: true,
|
|
46
|
+
tryRotate: true,
|
|
47
|
+
});
|
|
48
|
+
if (results[0]?.text) {
|
|
49
|
+
return results[0].text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fallbackResults = await decoder.readBarcodes(imageData, {
|
|
53
|
+
binarizer: 'LocalAverage',
|
|
54
|
+
downscaleThreshold: 300,
|
|
55
|
+
formats: ['QRCode'],
|
|
56
|
+
maxNumberOfSymbols: 1,
|
|
57
|
+
tryDenoise: true,
|
|
58
|
+
tryDownscale: true,
|
|
59
|
+
tryHarder: true,
|
|
60
|
+
tryInvert: true,
|
|
61
|
+
tryRotate: true,
|
|
62
|
+
});
|
|
63
|
+
return fallbackResults[0]?.text;
|
|
64
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
12
|
-
import { MemoryRouter } from 'react-router-dom';
|
|
12
|
+
import { MemoryRouter, useLocation } from 'react-router-dom';
|
|
13
13
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
14
|
|
|
15
15
|
const { allowMock, appMock, flowModelRendererSpy } = vi.hoisted(() => {
|
|
@@ -270,28 +270,47 @@ describe('TopbarActionsBar helpers', () => {
|
|
|
270
270
|
expect(link).not.toHaveAttribute('target', '_blank');
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
-
it('
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
273
|
+
it.each(['/nocobase/v', '/v', '/nocobase/v/apps/jhb20'])(
|
|
274
|
+
'should navigate sub-app settings without document navigation with basename %s',
|
|
275
|
+
(basename) => {
|
|
276
|
+
appMock.current.router.getBasename = () => basename;
|
|
277
|
+
const items = getTopbarPluginSettingsItems({
|
|
278
|
+
canManagePlugins: false,
|
|
279
|
+
t: (key) => key,
|
|
280
|
+
settings: [
|
|
281
|
+
{
|
|
282
|
+
key: 'ai',
|
|
283
|
+
name: 'ai',
|
|
284
|
+
title: 'AI employees',
|
|
285
|
+
path: '/admin/settings/ai',
|
|
286
|
+
icon: null,
|
|
287
|
+
componentLoader: async () => null,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
});
|
|
291
|
+
const item = items[0];
|
|
292
|
+
if (!item || !('label' in item)) {
|
|
293
|
+
throw new Error('Expected settings menu item');
|
|
294
|
+
}
|
|
295
|
+
const appBase = basename.endsWith('/apps/jhb20') ? basename : `${basename}/apps/jhb20`;
|
|
296
|
+
const targetHref = `${appBase}/admin/settings/ai`;
|
|
297
|
+
const LocationDisplay = () => <output aria-label="Current route">{useLocation().pathname}</output>;
|
|
298
|
+
render(
|
|
299
|
+
<MemoryRouter basename={basename} initialEntries={[`${appBase}/admin/a3pq1t1773a`]}>
|
|
300
|
+
{item.label}
|
|
301
|
+
<LocationDisplay />
|
|
302
|
+
</MemoryRouter>,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const link = screen.getByRole('link', { name: 'AI employees' });
|
|
306
|
+
expect(link).toHaveAttribute('href', targetHref);
|
|
307
|
+
expect(link).not.toHaveAttribute('target', '_blank');
|
|
308
|
+
const click = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
|
|
309
|
+
fireEvent(link, click);
|
|
310
|
+
expect(click.defaultPrevented).toBe(true);
|
|
311
|
+
expect(screen.getByLabelText('Current route')).toHaveTextContent(targetHref.slice(basename.length));
|
|
312
|
+
},
|
|
313
|
+
);
|
|
295
314
|
|
|
296
315
|
it('should not treat admin-like paths as admin runtime', () => {
|
|
297
316
|
const items = getTopbarPluginSettingsItems({
|
|
@@ -71,6 +71,8 @@ interface Props {
|
|
|
71
71
|
enableDateVariableAsConstant?: boolean;
|
|
72
72
|
/** 是否允许在变量选择器中使用 RunJS。默认 true,保持历史行为。 */
|
|
73
73
|
allowRunJS?: boolean;
|
|
74
|
+
/** 是否允许在变量选择器中使用内置日期变量。默认 true。 */
|
|
75
|
+
allowDateVariables?: boolean;
|
|
74
76
|
maxAssociationFieldDepth?: number;
|
|
75
77
|
disabled?: boolean;
|
|
76
78
|
variableConverters?: VariableInputProps['converters'];
|
|
@@ -444,6 +446,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
444
446
|
preferFormItemFieldModel,
|
|
445
447
|
associationFieldNamesOverride,
|
|
446
448
|
allowRunJS = true,
|
|
449
|
+
allowDateVariables = true,
|
|
447
450
|
maxAssociationFieldDepth = 2,
|
|
448
451
|
disabled = false,
|
|
449
452
|
variableConverters,
|
|
@@ -972,6 +975,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
972
975
|
style={{ width: '100%' }}
|
|
973
976
|
clearValue={''}
|
|
974
977
|
allowRunJS={allowRunJS}
|
|
978
|
+
allowDateVariables={allowDateVariables}
|
|
975
979
|
disabled={disabled}
|
|
976
980
|
converters={variableConverters}
|
|
977
981
|
/>
|
|
@@ -71,6 +71,7 @@ export type FieldValueVariableInputProps = Omit<
|
|
|
71
71
|
isDateLikeField: boolean;
|
|
72
72
|
dateComponentProps: DateVariableComponentProps;
|
|
73
73
|
allowRunJS?: boolean;
|
|
74
|
+
allowDateVariables?: boolean;
|
|
74
75
|
converters?: VariableInputProps['converters'];
|
|
75
76
|
};
|
|
76
77
|
|
|
@@ -149,6 +150,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
149
150
|
isDateLikeField,
|
|
150
151
|
dateComponentProps,
|
|
151
152
|
allowRunJS = true,
|
|
153
|
+
allowDateVariables = true,
|
|
152
154
|
converters,
|
|
153
155
|
clearValue = '',
|
|
154
156
|
disabled = false,
|
|
@@ -168,7 +170,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
168
170
|
return Component;
|
|
169
171
|
}, [dateComponentProps, isDateLikeField]);
|
|
170
172
|
|
|
171
|
-
const parsedDateConfig = parseCtxDateExpressionConfig(value);
|
|
173
|
+
const parsedDateConfig = allowDateVariables ? parseCtxDateExpressionConfig(value) : undefined;
|
|
172
174
|
const restoreLegacyNowForPureDate =
|
|
173
175
|
dateComponentProps.exactNormalizeMode === 'date' &&
|
|
174
176
|
parsedDateConfig?.kind === 'preset' &&
|
|
@@ -230,14 +232,18 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
230
232
|
paths: ['null'],
|
|
231
233
|
render: (props) => <NullComponent {...props} />,
|
|
232
234
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
235
|
+
...(allowDateVariables
|
|
236
|
+
? [
|
|
237
|
+
{
|
|
238
|
+
title: tExpr('Date'),
|
|
239
|
+
name: 'date',
|
|
240
|
+
type: 'date',
|
|
241
|
+
paths: ['date'],
|
|
242
|
+
selectable: false,
|
|
243
|
+
children: dateChildren,
|
|
244
|
+
} satisfies MetaTreeNode,
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
241
247
|
...(allowRunJS
|
|
242
248
|
? [
|
|
243
249
|
{
|
|
@@ -257,6 +263,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
257
263
|
DateEditor,
|
|
258
264
|
NullComponent,
|
|
259
265
|
RunJSComponent,
|
|
266
|
+
allowDateVariables,
|
|
260
267
|
allowRunJS,
|
|
261
268
|
baseMetaTree,
|
|
262
269
|
dateComponentProps.exactNormalizeMode,
|
|
@@ -294,7 +301,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
294
301
|
const firstPath = meta?.paths?.[0];
|
|
295
302
|
if (firstPath === 'constant') return ConstantComponent;
|
|
296
303
|
if (firstPath === 'null') return NullComponent;
|
|
297
|
-
if (firstPath === 'date') return DateEditor;
|
|
304
|
+
if (allowDateVariables && firstPath === 'date') return DateEditor;
|
|
298
305
|
if (allowRunJS && firstPath === 'runjs') return RunJSComponent;
|
|
299
306
|
return null;
|
|
300
307
|
},
|
|
@@ -304,7 +311,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
304
311
|
const firstPath = item?.paths?.[0];
|
|
305
312
|
if (firstPath === 'constant') return '';
|
|
306
313
|
if (firstPath === 'null') return null;
|
|
307
|
-
if (firstPath === 'date') {
|
|
314
|
+
if (allowDateVariables && firstPath === 'date') {
|
|
308
315
|
return createInitialDateConfig(item.paths[1], isDateLikeField, dateComponentProps);
|
|
309
316
|
}
|
|
310
317
|
if (allowRunJS && firstPath === 'runjs') return { code: '', version: 'v2' };
|
|
@@ -315,7 +322,9 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
315
322
|
if (external !== undefined) return external;
|
|
316
323
|
if (currentValue === null) return ['null'];
|
|
317
324
|
if (allowRunJS && isRunJSValue(currentValue)) return ['runjs'];
|
|
318
|
-
if (isDateVariableEditConfig(currentValue))
|
|
325
|
+
if (allowDateVariables && isDateVariableEditConfig(currentValue)) {
|
|
326
|
+
return ['date', getDateNodeName(currentValue)];
|
|
327
|
+
}
|
|
319
328
|
return typeof currentValue === 'string' && isVariableExpression(currentValue)
|
|
320
329
|
? parseValueToPath(currentValue)
|
|
321
330
|
: ['constant'];
|
|
@@ -50,6 +50,7 @@ function renderInput(options?: {
|
|
|
50
50
|
value?: unknown;
|
|
51
51
|
isDateLikeField?: boolean;
|
|
52
52
|
dateComponentProps?: DateVariableComponentProps;
|
|
53
|
+
allowDateVariables?: boolean;
|
|
53
54
|
}) {
|
|
54
55
|
const onChange = vi.fn();
|
|
55
56
|
render(
|
|
@@ -62,6 +63,7 @@ function renderInput(options?: {
|
|
|
62
63
|
runJSComponent={RunJSComponent}
|
|
63
64
|
isDateLikeField={options?.isDateLikeField ?? false}
|
|
64
65
|
dateComponentProps={options?.dateComponentProps ?? DEFAULT_DATE_VARIABLE_COMPONENT_PROPS}
|
|
66
|
+
allowDateVariables={options?.allowDateVariables}
|
|
65
67
|
/>,
|
|
66
68
|
);
|
|
67
69
|
return onChange;
|
|
@@ -110,6 +112,13 @@ describe('FieldValueVariableInput', () => {
|
|
|
110
112
|
expect(tree[4].name).toBe('currentUser');
|
|
111
113
|
});
|
|
112
114
|
|
|
115
|
+
it('omits the built-in Date variables when they are disabled', async () => {
|
|
116
|
+
renderInput({ isDateLikeField: true, allowDateVariables: false });
|
|
117
|
+
|
|
118
|
+
const tree = await resolveMetaTree();
|
|
119
|
+
expect(tree.map((node) => node.name)).toEqual(['constant', 'null', 'runjs', 'currentUser']);
|
|
120
|
+
});
|
|
121
|
+
|
|
113
122
|
it('does not allow Now for pure date fields', async () => {
|
|
114
123
|
const dateComponentProps: DateVariableComponentProps = {
|
|
115
124
|
...DEFAULT_DATE_VARIABLE_COMPONENT_PROPS,
|
|
@@ -922,7 +922,6 @@ export class GridModel<T extends { subModels: { items: FlowModel[] } } = Default
|
|
|
922
922
|
const baseLayout = this.context.isMobileLayout
|
|
923
923
|
? normalizeGridLayout({
|
|
924
924
|
rows: transformRowsToSingleColumn(projectLayoutToLegacyRows(rawLayout).rows),
|
|
925
|
-
itemUids: this.getItemUids(),
|
|
926
925
|
})
|
|
927
926
|
: rawLayout;
|
|
928
927
|
const baseProjection = projectLayoutToLegacyRows(baseLayout);
|
|
@@ -115,7 +115,28 @@ export class AssignFormGridModel extends FormGridModel {
|
|
|
115
115
|
(existing as any).assignValue = value;
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
|
-
|
|
118
|
+
if (!collection) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const field = (collection.getFields?.() || []).find((f: any) => f.name === fieldName);
|
|
122
|
+
|
|
123
|
+
if (!field) {
|
|
124
|
+
const created = this.addSubModel('items', {
|
|
125
|
+
use: 'AssignFormItemModel',
|
|
126
|
+
stepParams: {
|
|
127
|
+
fieldSettings: {
|
|
128
|
+
init: {
|
|
129
|
+
dataSourceKey: collection?.dataSourceKey,
|
|
130
|
+
collectionName: collection?.name,
|
|
131
|
+
fieldPath: fieldName,
|
|
132
|
+
},
|
|
133
|
+
assignValue: { value },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
created['assignValue'] = value;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
119
140
|
|
|
120
141
|
const binding = EditableItemModel.getDefaultBindingByField(this.context, field);
|
|
121
142
|
if (!binding) {
|
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { Input } from 'antd';
|
|
12
12
|
import { define, observable } from '@formily/reactive';
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
EditableItemModel,
|
|
15
|
+
FieldDeletePlaceholder,
|
|
16
|
+
FlowModelRenderer,
|
|
17
|
+
FormItem,
|
|
18
|
+
jioToJoiSchema,
|
|
19
|
+
tExpr,
|
|
20
|
+
} from '@nocobase/flow-engine';
|
|
14
21
|
// 无需类型导入(避免未使用的类型)
|
|
15
22
|
import { FormItemModel } from '../form/FormItemModel';
|
|
16
23
|
import { EditFormModel } from '../form/EditFormModel';
|
|
@@ -160,11 +167,15 @@ export class AssignFormItemModel extends FormItemModel {
|
|
|
160
167
|
|
|
161
168
|
getAssignedEntry(): [string, any] | null {
|
|
162
169
|
const name = this.fieldPath;
|
|
163
|
-
if (!name) return null;
|
|
170
|
+
if (!name || !this.collectionField) return null;
|
|
164
171
|
return [name, this.assignValue];
|
|
165
172
|
}
|
|
166
173
|
|
|
167
174
|
render() {
|
|
175
|
+
if (!this.collectionField) {
|
|
176
|
+
return <FieldDeletePlaceholder />;
|
|
177
|
+
}
|
|
178
|
+
|
|
168
179
|
// 与 FormItemModel.render 结构保持一致,仅替换内部渲染为 VariableInput + 常量编辑器
|
|
169
180
|
const ctx: any = this.context;
|
|
170
181
|
const collection = ctx.collection;
|
|
@@ -392,7 +403,7 @@ AssignFormItemModel.registerFlow({
|
|
|
392
403
|
},
|
|
393
404
|
defaultParams: (ctx) => {
|
|
394
405
|
return {
|
|
395
|
-
label: (ctx.model as any).collectionField.
|
|
406
|
+
label: (ctx.model as any).collectionField?.title || (ctx.model as any).fieldPath,
|
|
396
407
|
};
|
|
397
408
|
},
|
|
398
409
|
handler(ctx, params) {
|