@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.21

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 (91) hide show
  1. package/es/RouteRepository.d.ts +6 -0
  2. package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
  3. package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
  4. package/es/flow/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -0
  5. package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
  6. package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
  7. package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
  8. package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
  9. package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
  10. package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
  11. package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
  12. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
  13. package/es/index.mjs +100 -100
  14. package/lib/index.js +102 -102
  15. package/package.json +9 -8
  16. package/src/BaseApplication.tsx +2 -0
  17. package/src/RouteRepository.ts +14 -0
  18. package/src/__tests__/RouteRepository.test.ts +51 -0
  19. package/src/__tests__/app.test.tsx +13 -0
  20. package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
  21. package/src/collection-manager/field-configure.ts +2 -2
  22. package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
  23. package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
  24. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
  25. package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
  26. package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
  27. package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
  28. package/src/flow/actions/__tests__/validation.test.ts +48 -0
  29. package/src/flow/admin-shell/admin-layout/MobileTopbarActionsContext.ts +13 -0
  30. package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
  31. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -0
  32. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
  33. package/src/flow/components/FieldAssignValueInput.tsx +4 -0
  34. package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
  35. package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
  36. package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
  37. package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
  38. package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
  39. package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
  40. package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
  41. package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
  42. package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
  43. package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
  44. package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
  45. package/src/flow/models/actions/LinkActionModel.tsx +11 -9
  46. package/src/flow/models/actions/LinkActionUtils.ts +4 -2
  47. package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
  48. package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
  49. package/src/flow/models/base/GridModel.tsx +0 -1
  50. package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
  51. package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
  52. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
  53. package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
  54. package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
  55. package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
  56. package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
  57. package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
  58. package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
  59. package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
  60. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
  61. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
  62. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
  63. package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
  64. package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
  65. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
  66. package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
  67. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
  68. package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +11 -2
  69. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
  70. package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
  71. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +13 -1
  72. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
  73. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
  74. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
  75. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
  76. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
  77. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
  78. package/src/flow/models/fields/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -0
  79. package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
  80. package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
  81. package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
  82. package/src/flow/models/fields/InputFieldModel.tsx +1 -1
  83. package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
  84. package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
  85. package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
  86. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
  87. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
  88. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
  89. package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
  90. package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
  91. package/src/flow/models/topbar/UserCenterTopbarActionModel.tsx +8 -4
@@ -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
+ }
@@ -0,0 +1,48 @@
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 { describe, expect, it, vi } from 'vitest';
11
+ import { validation } from '../validation';
12
+
13
+ vi.mock('../../../flow-compat', () => ({ FieldValidation: () => null }));
14
+
15
+ describe('validation action', () => {
16
+ it('rejects excess decimal places with a translated error and retains collection rules', async () => {
17
+ const setProps = vi.fn();
18
+ const collectionRule = { validator: vi.fn().mockResolvedValue(undefined) };
19
+ const t = vi.fn((key: string, options?: Record<string, unknown>) => {
20
+ return `${options?.label} 精度不能超过 ${options?.limit} 位小数`;
21
+ });
22
+ const ctx = {
23
+ model: {
24
+ props: { label: '工龄' },
25
+ collectionField: { getComponentProps: () => ({ rules: [collectionRule] }) },
26
+ setProps,
27
+ },
28
+ t,
29
+ } as unknown as Parameters<typeof validation.handler>[0];
30
+
31
+ await validation.handler(ctx, {
32
+ validation: { type: 'number', rules: [{ name: 'precision', args: { limit: 2 } }] },
33
+ });
34
+
35
+ const { rules } = setProps.mock.calls[0][0] as {
36
+ rules: { validator: (rule: unknown, value: unknown) => Promise<void> }[];
37
+ };
38
+ expect(rules).toHaveLength(2);
39
+ expect(rules[0]).toBe(collectionRule);
40
+ await expect(rules[1].validator({}, 39.2234)).rejects.toBe('工龄 精度不能超过 2 位小数');
41
+ expect(t).toHaveBeenCalledWith(
42
+ 'number.precision',
43
+ expect.objectContaining({ ns: 'data-source-main', label: '工龄', limit: 2 }),
44
+ );
45
+ await expect(rules[1].validator({}, 39.22)).resolves.toBeUndefined();
46
+ await expect(rules[1].validator({}, null)).resolves.toBeUndefined();
47
+ });
48
+ });
@@ -0,0 +1,13 @@
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 { createContext } from 'react';
11
+
12
+ // Internal callback for dismissing the mobile topbar after a nested menu action completes.
13
+ export const MobileTopbarActionsContext = createContext<(() => void) | undefined>(undefined);
@@ -11,13 +11,14 @@ import { EllipsisOutlined } from '@ant-design/icons';
11
11
  import { css } from '@emotion/css';
12
12
  import { FlowModelRenderer } from '@nocobase/flow-engine';
13
13
  import { ConfigProvider, Divider, Popover, theme } from 'antd';
14
- import React, { useCallback, useMemo, useState } from 'react';
14
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
15
15
  import { ErrorBoundary } from 'react-error-boundary';
16
16
  import { useAclSnippets } from '../../../acl/useAclSnippets';
17
17
  import type { CustomToken } from '../../../theme';
18
18
  import { TopbarActionModel } from '../../models/topbar/TopbarActionModel';
19
19
  import { USER_CENTER_ACTION_ID } from '../../models/topbar/UserCenterTopbarActionModel';
20
20
  import { HelpLite } from './HelpLite';
21
+ import { MobileTopbarActionsContext } from './MobileTopbarActionsContext';
21
22
 
22
23
  const topbarActionsBarClassName = css`
23
24
  display: inline-flex;
@@ -183,11 +184,21 @@ export const TopbarActionsBar = React.memo((props: { actions: TopbarActionModel[
183
184
  setOpen(false);
184
185
  }, []);
185
186
 
187
+ useEffect(() => {
188
+ if (!props.isMobile) {
189
+ setOpen(false);
190
+ }
191
+ }, [props.isMobile]);
192
+
186
193
  if (props.isMobile) {
187
194
  return (
188
195
  <Popover
189
196
  rootClassName={popoverStyle}
190
- content={<TopbarActionsContent actions={props.actions} onActionClick={handleContentClick} />}
197
+ content={
198
+ <MobileTopbarActionsContext.Provider value={handleContentClick}>
199
+ <TopbarActionsContent actions={props.actions} onActionClick={handleContentClick} />
200
+ </MobileTopbarActionsContext.Provider>
201
+ }
191
202
  color={customToken.colorBgHeader}
192
203
  trigger="click"
193
204
  open={open}
@@ -0,0 +1,205 @@
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 { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
11
+ import { Drawer, Input } from 'antd';
12
+ import React, { useState } from 'react';
13
+ import { afterEach, describe, expect, it, vi } from 'vitest';
14
+
15
+ vi.mock('@nocobase/flow-engine', () => ({
16
+ FlowModel: class {
17
+ uid: string;
18
+ context = { t: (value: string) => value };
19
+
20
+ constructor(options: { uid: string }) {
21
+ this.uid = options.uid;
22
+ }
23
+ },
24
+ FlowModelRenderer: ({ model }: { model: { render: () => React.ReactNode } }) => model.render(),
25
+ observer: (component: React.ComponentType) => component,
26
+ }));
27
+
28
+ vi.mock('../../../models/topbar/TopbarActionModel', async () => {
29
+ const { FlowModel } = await import('@nocobase/flow-engine');
30
+ return {
31
+ TopbarActionModel: class extends FlowModel {
32
+ isHidden() {
33
+ return false;
34
+ }
35
+
36
+ getActionId() {
37
+ return 'user-center';
38
+ }
39
+
40
+ renderWrapper(node: React.ReactNode) {
41
+ return node;
42
+ }
43
+
44
+ renderButton() {
45
+ return <button data-testid="user-center-button">User center</button>;
46
+ }
47
+ },
48
+ };
49
+ });
50
+
51
+ vi.mock('../../../../acl/useAclSnippets', () => ({
52
+ useAclSnippets: () => ({ allow: () => true }),
53
+ }));
54
+
55
+ vi.mock('../HelpLite', () => ({ HelpLite: () => null }));
56
+
57
+ import {
58
+ UserCenterActionItemModel,
59
+ UserCenterSelectItemModel,
60
+ UserCenterTopbarActionModel,
61
+ } from '../../../models/topbar/UserCenterTopbarActionModel';
62
+ import { TopbarActionsBar } from '../TopbarActionsBar';
63
+
64
+ afterEach(cleanup);
65
+
66
+ function createUserCenter(onClick: () => Promise<void>, closeOnClick = true) {
67
+ const item = new UserCenterActionItemModel({ uid: 'test-edit-profile', use: 'UserCenterActionItemModel' });
68
+ item.label = 'Edit profile';
69
+ item.onClick = onClick;
70
+ item.closeOnClick = closeOnClick;
71
+ const model = new UserCenterTopbarActionModel({ uid: 'test-user-center', use: 'UserCenterTopbarActionModel' });
72
+ vi.spyOn(model, 'getPreparedItems').mockImplementation(async (closeDropdown) => {
73
+ item.setCloseDropdown(closeDropdown);
74
+ return [item];
75
+ });
76
+ return model;
77
+ }
78
+
79
+ async function openUserMenu(isMobile: boolean) {
80
+ if (isMobile) {
81
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
82
+ }
83
+ fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
84
+ const action = await screen.findByRole('button', { name: 'Edit profile' });
85
+ await waitFor(() => expect(action).toBeVisible());
86
+ return action;
87
+ }
88
+
89
+ describe('mobile user center popover', () => {
90
+ it('closes both menus after selecting an action and keeps the opened drawer mounted', async () => {
91
+ const model = createUserCenter(vi.fn());
92
+ const Test = () => {
93
+ const [open, setOpen] = useState(false);
94
+ model.getPreparedItems = async (closeDropdown) => {
95
+ const item = new UserCenterActionItemModel({ uid: 'profile', use: 'UserCenterActionItemModel' });
96
+ item.label = 'Edit profile';
97
+ item.onClick = async () => setOpen(true);
98
+ item.setCloseDropdown(closeDropdown);
99
+ return [item];
100
+ };
101
+ return (
102
+ <>
103
+ <TopbarActionsBar actions={[model]} isMobile />
104
+ <Drawer open={open} title="Edit profile">
105
+ <Input aria-label="Nickname" />
106
+ </Drawer>
107
+ </>
108
+ );
109
+ };
110
+ render(<Test />);
111
+
112
+ fireEvent.click(await openUserMenu(true));
113
+
114
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
115
+ expect(screen.getByRole('textbox', { name: 'Nickname' })).toBeVisible();
116
+ expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument();
117
+
118
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
119
+ fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
120
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Edit profile' })).toBeVisible());
121
+ });
122
+
123
+ it('respects actions that keep the menu open', async () => {
124
+ const onClick = vi.fn(async () => {});
125
+ render(<TopbarActionsBar actions={[createUserCenter(onClick, false)]} isMobile />);
126
+
127
+ fireEvent.click(await openUserMenu(true));
128
+
129
+ await waitFor(() => expect(onClick).toHaveBeenCalledOnce());
130
+ expect(screen.getByRole('tooltip')).toBeVisible();
131
+ expect(screen.getByRole('button', { name: 'Edit profile' })).toBeVisible();
132
+ });
133
+
134
+ it('keeps the topbar open when the user only leaves the nested menu', async () => {
135
+ render(<TopbarActionsBar actions={[createUserCenter(vi.fn())]} isMobile />);
136
+ const action = await openUserMenu(true);
137
+
138
+ const dropdown = action.closest('.ant-dropdown');
139
+ expect(dropdown).not.toBeNull();
140
+ if (dropdown) {
141
+ fireEvent.mouseLeave(dropdown);
142
+ }
143
+ fireEvent.mouseLeave(screen.getByTestId('user-center-button'));
144
+
145
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument());
146
+ expect(screen.getByRole('tooltip')).toBeVisible();
147
+ });
148
+
149
+ it('keeps desktop actions working without a mobile popover', async () => {
150
+ const onClick = vi.fn(async () => {});
151
+ render(<TopbarActionsBar actions={[createUserCenter(onClick)]} />);
152
+
153
+ fireEvent.click(await openUserMenu(false));
154
+
155
+ await waitFor(() => expect(screen.queryByRole('button', { name: 'Edit profile' })).not.toBeInTheDocument());
156
+ expect(onClick).toHaveBeenCalledOnce();
157
+ expect(screen.queryByRole('img', { name: 'ellipsis' })).not.toBeInTheDocument();
158
+ });
159
+
160
+ it.each([true, false])('respects closeOnChange=%s for preference selectors', async (closeOnChange) => {
161
+ const onChange = vi.fn(async () => {});
162
+ const item = new UserCenterSelectItemModel({ uid: 'language', use: 'UserCenterSelectItemModel' });
163
+ item.label = 'Language';
164
+ item.value = 'en';
165
+ item.options = [
166
+ { label: 'English', value: 'en' },
167
+ { label: 'Chinese', value: 'zh' },
168
+ ];
169
+ item.onChange = onChange;
170
+ item.closeOnChange = closeOnChange;
171
+ const model = createUserCenter(vi.fn());
172
+ model.getPreparedItems = async (closeDropdown) => {
173
+ item.setCloseDropdown(closeDropdown);
174
+ return [item];
175
+ };
176
+ render(<TopbarActionsBar actions={[model]} isMobile />);
177
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
178
+ fireEvent.mouseEnter(screen.getByTestId('user-center-button'));
179
+ const select = await screen.findByRole('combobox');
180
+ await waitFor(() => expect(select.closest('.ant-select')).toBeVisible());
181
+
182
+ fireEvent.mouseDown(select);
183
+ fireEvent.click(await screen.findByText('Chinese'));
184
+
185
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith('zh'));
186
+ if (closeOnChange) {
187
+ await waitFor(() => expect(screen.queryByRole('tooltip')).not.toBeInTheDocument());
188
+ } else {
189
+ expect(screen.getByRole('tooltip')).toBeVisible();
190
+ expect(select.closest('.ant-select')).toBeVisible();
191
+ }
192
+ });
193
+
194
+ it('does not reopen the previous popover after switching to desktop and back', async () => {
195
+ const actions = [createUserCenter(vi.fn())];
196
+ const { rerender } = render(<TopbarActionsBar actions={actions} isMobile />);
197
+ fireEvent.click(screen.getByRole('img', { name: 'ellipsis' }));
198
+ await waitFor(() => expect(screen.getByRole('tooltip')).toBeVisible());
199
+
200
+ rerender(<TopbarActionsBar actions={actions} isMobile={false} />);
201
+ rerender(<TopbarActionsBar actions={actions} isMobile />);
202
+
203
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
204
+ });
205
+ });