@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.
Files changed (32) hide show
  1. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  2. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  3. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  4. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  5. package/es/index.mjs +19 -19
  6. package/lib/index.js +38 -38
  7. package/package.json +9 -8
  8. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  9. package/src/collection-manager/field-configure.ts +2 -2
  10. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  11. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  12. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  13. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  14. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  15. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  16. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  17. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  18. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  19. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  20. package/src/flow/models/base/GridModel.tsx +0 -1
  21. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  22. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
  23. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  24. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  25. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  26. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  27. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  28. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  29. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  30. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  31. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  32. 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 scanQrVideoFrame(video: HTMLVideoElement, canvas: HTMLCanvasElement) {
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 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));
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(video, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, targetWidth, targetHeight);
114
- const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
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
- function startLiveQrScan(elementId: string, onScanSuccess: (text: string) => void) {
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
- const decodedText = scanQrVideoFrame(video, canvas);
130
- if (decodedText) {
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
- timer = window.setTimeout(scan, LIVE_QR_SCAN_INTERVAL);
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('should keep sub-app admin settings in the current window inside sub-app admin runtime', () => {
274
- const items = getTopbarPluginSettingsItems({
275
- canManagePlugins: false,
276
- t: (key) => key,
277
- settings: [
278
- {
279
- key: 'routes',
280
- name: 'routes',
281
- title: 'Routes',
282
- path: '/admin/settings/routes',
283
- icon: null,
284
- componentLoader: async () => null,
285
- },
286
- ] as any,
287
- });
288
-
289
- renderSettingsLabel((items as any[])[0].label, '/apps/a_9xlild35jir/admin/settings/routes');
290
-
291
- const link = screen.getByRole('link', { name: 'Routes' });
292
- expect(link).toHaveAttribute('href', '/nocobase/v/apps/a_9xlild35jir/admin/settings/routes');
293
- expect(link).not.toHaveAttribute('target', '_blank');
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
- title: tExpr('Date'),
235
- name: 'date',
236
- type: 'date',
237
- paths: ['date'],
238
- selectable: false,
239
- children: dateChildren,
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)) return ['date', getDateNodeName(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
- const field = (collection?.getFields?.() || []).find((f: any) => f.name === fieldName);
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 { FlowModelRenderer, FormItem, tExpr, EditableItemModel, jioToJoiSchema } from '@nocobase/flow-engine';
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.title,
406
+ label: (ctx.model as any).collectionField?.title || (ctx.model as any).fieldPath,
396
407
  };
397
408
  },
398
409
  handler(ctx, params) {