@nocobase/flow-engine 2.3.0-alpha.1 → 3.0.0-alpha.2
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/lib/acl/Acl.d.ts +2 -1
- package/lib/acl/Acl.js +28 -0
- package/lib/components/MobilePopup.js +14 -3
- package/lib/components/subModel/LazyDropdown.js +41 -26
- package/lib/locale/en-US.json +2 -0
- package/lib/locale/index.d.ts +4 -0
- package/lib/locale/zh-CN.json +2 -0
- package/package.json +4 -4
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/MobilePopup.tsx +16 -4
- package/src/components/__tests__/MobilePopup.test.tsx +42 -1
- package/src/components/subModel/LazyDropdown.tsx +44 -26
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/locale/__tests__/index.test.ts +21 -0
- package/src/locale/en-US.json +2 -0
- package/src/locale/zh-CN.json +2 -0
package/lib/acl/Acl.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ interface CheckOptions {
|
|
|
5
5
|
actionName: string;
|
|
6
6
|
fields?: string[];
|
|
7
7
|
recordPkValue?: string | number;
|
|
8
|
-
allowedActions
|
|
8
|
+
allowedActions?: Record<string, Array<string | number>>;
|
|
9
9
|
}
|
|
10
10
|
export declare class ACL {
|
|
11
11
|
private flowEngine;
|
|
@@ -26,6 +26,7 @@ export declare class ACL {
|
|
|
26
26
|
verifyScope: (actionName: string, recordPkValue: any, allowedActions: any) => boolean;
|
|
27
27
|
parseAction(options: CheckOptions): any;
|
|
28
28
|
parseField(options: CheckOptions): boolean;
|
|
29
|
+
can(options: CheckOptions): boolean;
|
|
29
30
|
aclCheck(options: CheckOptions): Promise<boolean>;
|
|
30
31
|
}
|
|
31
32
|
export {};
|
package/lib/acl/Acl.js
CHANGED
|
@@ -154,6 +154,34 @@ const _ACL = class _ACL {
|
|
|
154
154
|
const allowed = whitelist.includes(fields[0]);
|
|
155
155
|
return allowed;
|
|
156
156
|
}
|
|
157
|
+
can(options) {
|
|
158
|
+
var _a;
|
|
159
|
+
const { allowAll } = this.data;
|
|
160
|
+
if (allowAll) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
const { actionName, allowedActions, recordPkValue } = options;
|
|
164
|
+
const hasRecordPkValue = recordPkValue !== void 0 && recordPkValue !== null;
|
|
165
|
+
const recordPermission = hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
|
|
166
|
+
if (hasRecordPkValue && allowedActions && recordPermission !== true) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const params = this.parseAction(options);
|
|
170
|
+
if (!params) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (!import_lodash.default.isEmpty(params.filter) && recordPermission !== true) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
if (!((_a = options.fields) == null ? void 0 : _a.length)) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
const allowedFields = [].concat(params.whitelist || []).concat(params.fields || []).concat(params.appends || []);
|
|
180
|
+
if (!allowedFields.length) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
return options.fields.every((field) => allowedFields.includes(field));
|
|
184
|
+
}
|
|
157
185
|
async aclCheck(options) {
|
|
158
186
|
const { allowAll } = this.data;
|
|
159
187
|
if (allowAll) {
|
|
@@ -47,25 +47,36 @@ var import_react_i18next = require("react-i18next");
|
|
|
47
47
|
var import_lazy_helper = require("../lazy-helper");
|
|
48
48
|
const { Popup } = (0, import_lazy_helper.lazy)(() => import("antd-mobile"), "Popup");
|
|
49
49
|
const { CloseOutline } = (0, import_lazy_helper.lazy)(() => import("antd-mobile-icons"), "CloseOutline");
|
|
50
|
+
const getMobilePopupMaxHeight = /* @__PURE__ */ __name(() => {
|
|
51
|
+
var _a;
|
|
52
|
+
if (typeof CSS !== "undefined" && ((_a = CSS.supports) == null ? void 0 : _a.call(CSS, "height", "100dvh"))) {
|
|
53
|
+
return "calc(100dvh - var(--nb-mobile-page-header-height, 46px))";
|
|
54
|
+
}
|
|
55
|
+
return "calc(100vh - var(--nb-mobile-page-header-height, 46px))";
|
|
56
|
+
}, "getMobilePopupMaxHeight");
|
|
50
57
|
const MobilePopup = /* @__PURE__ */ __name((props) => {
|
|
51
58
|
var _a;
|
|
52
59
|
const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
|
|
53
60
|
const { t } = (0, import_react_i18next.useTranslation)();
|
|
54
61
|
const { componentCls, hashId } = (0, import_MobilePopup.useMobileActionDrawerStyle)();
|
|
55
62
|
const bodyStyles = (_a = props.styles) == null ? void 0 : _a.body;
|
|
63
|
+
const defaultMaxHeight = getMobilePopupMaxHeight();
|
|
56
64
|
const popupStyle = (0, import_react.useMemo)(() => {
|
|
57
65
|
return {
|
|
58
66
|
minHeight: (bodyStyles == null ? void 0 : bodyStyles.minHeight) ?? minHeight,
|
|
59
67
|
height: bodyStyles == null ? void 0 : bodyStyles.height,
|
|
60
|
-
maxHeight: bodyStyles == null ? void 0 : bodyStyles.maxHeight
|
|
68
|
+
maxHeight: (bodyStyles == null ? void 0 : bodyStyles.maxHeight) ?? defaultMaxHeight
|
|
61
69
|
};
|
|
62
|
-
}, [bodyStyles == null ? void 0 : bodyStyles.height, bodyStyles == null ? void 0 : bodyStyles.maxHeight, bodyStyles == null ? void 0 : bodyStyles.minHeight, minHeight]);
|
|
70
|
+
}, [bodyStyles == null ? void 0 : bodyStyles.height, bodyStyles == null ? void 0 : bodyStyles.maxHeight, bodyStyles == null ? void 0 : bodyStyles.minHeight, defaultMaxHeight, minHeight]);
|
|
63
71
|
const bodyStyle = (0, import_react.useMemo)(() => {
|
|
64
72
|
return {
|
|
65
73
|
padding: 0,
|
|
74
|
+
maxHeight: defaultMaxHeight,
|
|
75
|
+
overflowY: "auto",
|
|
76
|
+
overflowX: "hidden",
|
|
66
77
|
...bodyStyles
|
|
67
78
|
};
|
|
68
|
-
}, [bodyStyles]);
|
|
79
|
+
}, [bodyStyles, defaultMaxHeight]);
|
|
69
80
|
const handleCloseKeyDown = (0, import_react.useCallback)(
|
|
70
81
|
(event) => {
|
|
71
82
|
if (event.key !== "Enter" && event.key !== " ") {
|
|
@@ -347,19 +347,15 @@ const createSearchItem = /* @__PURE__ */ __name((item, searchKey, currentSearchV
|
|
|
347
347
|
e.stopPropagation();
|
|
348
348
|
},
|
|
349
349
|
onChange: (e) => {
|
|
350
|
-
var _a;
|
|
351
350
|
e.stopPropagation();
|
|
352
351
|
const value = e.target.value;
|
|
353
352
|
if (shouldActivateSearchSubmenu) {
|
|
354
353
|
activateSearchSubmenu(searchKey);
|
|
355
354
|
}
|
|
356
|
-
if (
|
|
355
|
+
if (e.nativeEvent.isComposing || searchHandlers.isComposing(searchKey)) {
|
|
357
356
|
searchHandlers.updateInputValue(searchKey, value);
|
|
358
357
|
return;
|
|
359
358
|
}
|
|
360
|
-
if (!value && shouldActivateSearchSubmenu) {
|
|
361
|
-
deactivateSearchSubmenu(searchKey);
|
|
362
|
-
}
|
|
363
359
|
searchHandlers.updateSearchValue(searchKey, value);
|
|
364
360
|
},
|
|
365
361
|
onCompositionStart: (e) => {
|
|
@@ -373,11 +369,7 @@ const createSearchItem = /* @__PURE__ */ __name((item, searchKey, currentSearchV
|
|
|
373
369
|
e.stopPropagation();
|
|
374
370
|
const value = e.currentTarget.value;
|
|
375
371
|
if (shouldActivateSearchSubmenu) {
|
|
376
|
-
|
|
377
|
-
activateSearchSubmenu(searchKey);
|
|
378
|
-
} else {
|
|
379
|
-
deactivateSearchSubmenu(searchKey);
|
|
380
|
-
}
|
|
372
|
+
activateSearchSubmenu(searchKey);
|
|
381
373
|
}
|
|
382
374
|
searchHandlers.endComposition(searchKey, value);
|
|
383
375
|
},
|
|
@@ -385,10 +377,20 @@ const createSearchItem = /* @__PURE__ */ __name((item, searchKey, currentSearchV
|
|
|
385
377
|
e.stopPropagation();
|
|
386
378
|
},
|
|
387
379
|
onKeyDown: (e) => {
|
|
380
|
+
if (e.key === "Escape" || e.key === "Tab") {
|
|
381
|
+
deactivateSearchSubmenu(searchKey);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (shouldActivateSearchSubmenu) {
|
|
385
|
+
activateSearchSubmenu(searchKey);
|
|
386
|
+
}
|
|
388
387
|
e.stopPropagation();
|
|
389
388
|
},
|
|
390
389
|
onMouseDown: (e) => {
|
|
391
390
|
e.stopPropagation();
|
|
391
|
+
if (shouldActivateSearchSubmenu) {
|
|
392
|
+
activateSearchSubmenu(searchKey);
|
|
393
|
+
}
|
|
392
394
|
},
|
|
393
395
|
size: "small",
|
|
394
396
|
style: {
|
|
@@ -409,7 +411,7 @@ const KEEP_OPEN_LABEL_STYLE = {
|
|
|
409
411
|
width: "100%"
|
|
410
412
|
};
|
|
411
413
|
const DROPDOWN_PERSIST_TTL_MS = 350;
|
|
412
|
-
const
|
|
414
|
+
const MENU_CLOSE_DELAY = 0.3;
|
|
413
415
|
const SUBMENU_MOTION_DISABLED = {
|
|
414
416
|
motionEnter: false,
|
|
415
417
|
motionLeave: false
|
|
@@ -417,13 +419,18 @@ const SUBMENU_MOTION_DISABLED = {
|
|
|
417
419
|
const dropdownPersistRegistry = /* @__PURE__ */ new Map();
|
|
418
420
|
const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
|
|
419
421
|
const engine = (0, import_provider.useFlowEngine)();
|
|
422
|
+
const { getPrefixCls } = import_react.default.useContext(import_antd.ConfigProvider.ConfigContext);
|
|
423
|
+
const triggerId = import_react.default.useId();
|
|
420
424
|
const [menuVisible, setMenuVisible] = (0, import_react.useState)(false);
|
|
421
425
|
const [openKeys, setOpenKeys] = (0, import_react.useState)(/* @__PURE__ */ new Set());
|
|
422
|
-
const [activeSearchKey, setActiveSearchKey] = (0, import_react.useState)(null);
|
|
423
426
|
const [rootItems, setRootItems] = (0, import_react.useState)([]);
|
|
424
427
|
const [rootLoading, setRootLoading] = (0, import_react.useState)(false);
|
|
428
|
+
const activeSearchKeyRef = (0, import_react.useRef)(null);
|
|
425
429
|
const closeByOutsideClickRef = (0, import_react.useRef)(false);
|
|
426
430
|
const skipPreserveActiveSearchRef = (0, import_react.useRef)(false);
|
|
431
|
+
const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
432
|
+
const defaultOpenClassName = `${getPrefixCls("dropdown", props.prefixCls)}-open`;
|
|
433
|
+
const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName].filter(Boolean).join(" ");
|
|
427
434
|
const dropdownMaxHeight = useNiceDropdownMaxHeight();
|
|
428
435
|
const t = engine.translate.bind(engine);
|
|
429
436
|
const { items: menuItems, keepDropdownOpen, persistKey, stateVersion, refreshKeys, ...dropdownMenuProps } = menu;
|
|
@@ -440,12 +447,12 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
|
|
|
440
447
|
useSubmenuStyles(menuVisible, dropdownMaxHeight);
|
|
441
448
|
const closeMenu = (0, import_react.useCallback)(() => {
|
|
442
449
|
setMenuVisible(false);
|
|
443
|
-
|
|
450
|
+
activeSearchKeyRef.current = null;
|
|
444
451
|
setOpenKeys(/* @__PURE__ */ new Set());
|
|
445
452
|
clearAllSearchValues();
|
|
446
453
|
}, [clearAllSearchValues]);
|
|
447
454
|
const activateSearchSubmenu = (0, import_react.useCallback)((key) => {
|
|
448
|
-
|
|
455
|
+
activeSearchKeyRef.current = key;
|
|
449
456
|
setOpenKeys((prev) => {
|
|
450
457
|
if (prev.has(key)) return prev;
|
|
451
458
|
const next = new Set(prev);
|
|
@@ -454,34 +461,38 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
|
|
|
454
461
|
});
|
|
455
462
|
}, []);
|
|
456
463
|
const deactivateSearchSubmenu = (0, import_react.useCallback)((key) => {
|
|
457
|
-
|
|
464
|
+
if (activeSearchKeyRef.current === key) {
|
|
465
|
+
activeSearchKeyRef.current = null;
|
|
466
|
+
}
|
|
458
467
|
}, []);
|
|
459
468
|
const closeActiveSearchForPath = (0, import_react.useCallback)(
|
|
460
469
|
(keyPath) => {
|
|
470
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
461
471
|
if (!activeSearchKey || keyPath === activeSearchKey || keyPath.startsWith(`${activeSearchKey}/`) || activeSearchKey.startsWith(`${keyPath}/`)) {
|
|
462
472
|
return;
|
|
463
473
|
}
|
|
464
474
|
skipPreserveActiveSearchRef.current = true;
|
|
465
475
|
clearSearchValue(activeSearchKey);
|
|
466
|
-
|
|
476
|
+
activeSearchKeyRef.current = null;
|
|
467
477
|
setOpenKeys((prev) => {
|
|
468
478
|
const next = new Set(prev);
|
|
469
479
|
next.delete(activeSearchKey);
|
|
470
480
|
return next;
|
|
471
481
|
});
|
|
472
482
|
},
|
|
473
|
-
[
|
|
483
|
+
[clearSearchValue]
|
|
474
484
|
);
|
|
475
485
|
const handleMenuOpenChange = (0, import_react.useCallback)(
|
|
476
486
|
(nextOpenKeys) => {
|
|
477
487
|
var _a, _b;
|
|
478
488
|
let normalized = normalizeOpenKeys(nextOpenKeys);
|
|
479
|
-
|
|
480
|
-
|
|
489
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
490
|
+
if (activeSearchKey && !normalized.includes(activeSearchKey)) {
|
|
491
|
+
if (skipPreserveActiveSearchRef.current) {
|
|
481
492
|
clearSearchValue(activeSearchKey);
|
|
482
|
-
|
|
493
|
+
activeSearchKeyRef.current = null;
|
|
483
494
|
} else {
|
|
484
|
-
normalized =
|
|
495
|
+
normalized = Array.from(openKeys);
|
|
485
496
|
}
|
|
486
497
|
}
|
|
487
498
|
if (!normalized.length && shouldPreventClose()) {
|
|
@@ -498,13 +509,15 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
|
|
|
498
509
|
(_b = dropdownMenuProps.onOpenChange) == null ? void 0 : _b.call(dropdownMenuProps, normalized);
|
|
499
510
|
skipPreserveActiveSearchRef.current = false;
|
|
500
511
|
},
|
|
501
|
-
[
|
|
512
|
+
[clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose]
|
|
502
513
|
);
|
|
503
514
|
(0, import_react.useEffect)(() => {
|
|
504
515
|
if (!menuVisible) return;
|
|
505
516
|
const markOutsideClick = /* @__PURE__ */ __name((event) => {
|
|
506
517
|
const target = event.target;
|
|
507
|
-
const
|
|
518
|
+
const isInsidePopup = target == null ? void 0 : target.closest(".ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup");
|
|
519
|
+
const isInsideCurrentTrigger = target == null ? void 0 : target.closest(`.${triggerOpenClassName}`);
|
|
520
|
+
const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
|
|
508
521
|
closeByOutsideClickRef.current = isOutside;
|
|
509
522
|
if (isOutside) {
|
|
510
523
|
closeMenu();
|
|
@@ -516,7 +529,7 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
|
|
|
516
529
|
document.removeEventListener("pointerdown", markOutsideClick, true);
|
|
517
530
|
document.removeEventListener("mousedown", markOutsideClick, true);
|
|
518
531
|
};
|
|
519
|
-
}, [closeMenu, menuVisible]);
|
|
532
|
+
}, [closeMenu, menuVisible, triggerOpenClassName]);
|
|
520
533
|
(0, import_react.useEffect)(() => {
|
|
521
534
|
if (!persistKey) return;
|
|
522
535
|
const until = dropdownPersistRegistry.get(persistKey) || 0;
|
|
@@ -734,13 +747,15 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
|
|
|
734
747
|
...props,
|
|
735
748
|
open: menuVisible,
|
|
736
749
|
destroyPopupOnHide: true,
|
|
750
|
+
mouseLeaveDelay: props.mouseLeaveDelay ?? MENU_CLOSE_DELAY,
|
|
751
|
+
openClassName: mergedOpenClassName,
|
|
737
752
|
overlayClassName,
|
|
738
753
|
placement: "bottomLeft",
|
|
739
754
|
menu: {
|
|
740
755
|
...dropdownMenuProps,
|
|
741
756
|
openKeys: Array.from(openKeys),
|
|
742
757
|
items,
|
|
743
|
-
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ??
|
|
758
|
+
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
|
|
744
759
|
motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
|
|
745
760
|
onClick: /* @__PURE__ */ __name(() => {
|
|
746
761
|
}, "onClick"),
|
|
@@ -752,7 +767,7 @@ const LazyDropdown = /* @__PURE__ */ __name(({ menu, ...props }) => {
|
|
|
752
767
|
}
|
|
753
768
|
},
|
|
754
769
|
onOpenChange: (visible, info) => {
|
|
755
|
-
if (!visible &&
|
|
770
|
+
if (!visible && activeSearchKeyRef.current && (info == null ? void 0 : info.source) === "trigger" && !closeByOutsideClickRef.current) {
|
|
756
771
|
return;
|
|
757
772
|
}
|
|
758
773
|
if (!visible && shouldPreventClose()) {
|
package/lib/locale/en-US.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"Add": "Add",
|
|
3
|
+
"Are you sure you want to perform the action?": "Are you sure you want to perform the action?",
|
|
3
4
|
"Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
|
|
4
5
|
"Are you sure to convert this template block to copy mode?": "Are you sure you want to convert this template block to copy mode?",
|
|
5
6
|
"Array index out of bounds": "Array index {{index}} out of bounds for '{{subKey}}'",
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"Other blocks": "Other blocks",
|
|
54
55
|
"Parent not found, cannot replace block": "Parent not found, cannot replace block",
|
|
55
56
|
"Previous step": "Previous step",
|
|
57
|
+
"Please Confirm": "Please Confirm",
|
|
56
58
|
"Replace current block with template?": "Replace current block with template?",
|
|
57
59
|
"Replaced with template block": "Replaced with template block",
|
|
58
60
|
"Render failed": "Render failed",
|
package/lib/locale/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
export declare const locales: {
|
|
10
10
|
'en-US': {
|
|
11
11
|
Add: string;
|
|
12
|
+
"Are you sure you want to perform the action?": string;
|
|
12
13
|
"Are you sure you want to delete this item? This action cannot be undone.": string;
|
|
13
14
|
"Are you sure to convert this template block to copy mode?": string;
|
|
14
15
|
"Array index out of bounds": string;
|
|
@@ -62,6 +63,7 @@ export declare const locales: {
|
|
|
62
63
|
"Other blocks": string;
|
|
63
64
|
"Parent not found, cannot replace block": string;
|
|
64
65
|
"Previous step": string;
|
|
66
|
+
"Please Confirm": string;
|
|
65
67
|
"Replace current block with template?": string;
|
|
66
68
|
"Replaced with template block": string;
|
|
67
69
|
"Render failed": string;
|
|
@@ -91,6 +93,7 @@ export declare const locales: {
|
|
|
91
93
|
};
|
|
92
94
|
'zh-CN': {
|
|
93
95
|
Add: string;
|
|
96
|
+
"Are you sure you want to perform the action?": string;
|
|
94
97
|
"Are you sure you want to delete this item? This action cannot be undone.": string;
|
|
95
98
|
"Are you sure to convert this template block to copy mode?": string;
|
|
96
99
|
"Array index out of bounds": string;
|
|
@@ -150,6 +153,7 @@ export declare const locales: {
|
|
|
150
153
|
OK: string;
|
|
151
154
|
"Other blocks": string;
|
|
152
155
|
"Previous step": string;
|
|
156
|
+
"Please Confirm": string;
|
|
153
157
|
"Render failed": string;
|
|
154
158
|
"Response record": string;
|
|
155
159
|
"Step configuration": string;
|
package/lib/locale/zh-CN.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"Add": "添加",
|
|
3
|
+
"Are you sure you want to perform the action?": "确定要执行此操作吗?",
|
|
3
4
|
"Are you sure you want to delete this item? This action cannot be undone.": "确定要删除此项吗?此操作不可撤销。",
|
|
4
5
|
"Are you sure to convert this template block to copy mode?": "确定将该模板区块转换为复制模式吗?",
|
|
5
6
|
"Array index out of bounds": "数组索引 {{index}} 超出 '{{subKey}}' 的边界",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"OK": "确定",
|
|
60
61
|
"Other blocks": "其他区块",
|
|
61
62
|
"Previous step": "上一步",
|
|
63
|
+
"Please Confirm": "请确认",
|
|
62
64
|
"Render failed": "渲染失败",
|
|
63
65
|
"Response record": "响应结果记录",
|
|
64
66
|
"Step configuration": "步骤配置",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/flow-engine",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0-alpha.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@formily/antd-v5": "1.x",
|
|
10
10
|
"@formily/reactive": "2.x",
|
|
11
|
-
"@nocobase/sdk": "
|
|
12
|
-
"@nocobase/shared": "
|
|
11
|
+
"@nocobase/sdk": "3.0.0-alpha.2",
|
|
12
|
+
"@nocobase/shared": "3.0.0-alpha.2",
|
|
13
13
|
"ahooks": "^3.7.2",
|
|
14
14
|
"axios": "^1.7.0",
|
|
15
15
|
"dayjs": "^1.11.9",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
],
|
|
38
38
|
"author": "NocoBase Team",
|
|
39
39
|
"license": "Apache-2.0",
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "a3faea21082b30989301de04591ee1b91fd4e6a5"
|
|
41
41
|
}
|
package/src/acl/Acl.tsx
CHANGED
|
@@ -16,7 +16,7 @@ interface CheckOptions {
|
|
|
16
16
|
actionName: string;
|
|
17
17
|
fields?: string[];
|
|
18
18
|
recordPkValue?: string | number;
|
|
19
|
-
allowedActions
|
|
19
|
+
allowedActions?: Record<string, Array<string | number>>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export class ACL {
|
|
@@ -149,6 +149,41 @@ export class ACL {
|
|
|
149
149
|
return allowed;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
can(options: CheckOptions): boolean {
|
|
153
|
+
const { allowAll } = this.data;
|
|
154
|
+
if (allowAll) {
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const { actionName, allowedActions, recordPkValue } = options;
|
|
159
|
+
const hasRecordPkValue = recordPkValue !== undefined && recordPkValue !== null;
|
|
160
|
+
const recordPermission =
|
|
161
|
+
hasRecordPkValue && allowedActions ? this.verifyScope(actionName, recordPkValue, allowedActions) : null;
|
|
162
|
+
if (hasRecordPkValue && allowedActions && recordPermission !== true) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const params = this.parseAction(options);
|
|
167
|
+
if (!params) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (!_.isEmpty(params.filter) && recordPermission !== true) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (!options.fields?.length) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const allowedFields: string[] = []
|
|
178
|
+
.concat(params.whitelist || [])
|
|
179
|
+
.concat(params.fields || [])
|
|
180
|
+
.concat(params.appends || []);
|
|
181
|
+
if (!allowedFields.length) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return options.fields.every((field) => allowedFields.includes(field));
|
|
185
|
+
}
|
|
186
|
+
|
|
152
187
|
async aclCheck(options: CheckOptions): Promise<boolean> {
|
|
153
188
|
// await this.load();
|
|
154
189
|
const { allowAll } = this.data;
|
|
@@ -71,6 +71,76 @@ describe('ACL', () => {
|
|
|
71
71
|
expect(notOk).toBe(false);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
it('checks record update scope before field permission', () => {
|
|
75
|
+
const payload = {
|
|
76
|
+
data: {
|
|
77
|
+
allowAll: false,
|
|
78
|
+
actionAlias: {},
|
|
79
|
+
resources: ['posts'],
|
|
80
|
+
actions: { 'posts:update': { whitelist: ['title'] } },
|
|
81
|
+
strategy: { actions: [] },
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const engine = makeEngine(payload);
|
|
85
|
+
const acl = new ACL(engine);
|
|
86
|
+
acl.setData(payload.data);
|
|
87
|
+
|
|
88
|
+
const options = {
|
|
89
|
+
dataSourceKey: 'main',
|
|
90
|
+
resourceName: 'posts',
|
|
91
|
+
actionName: 'update',
|
|
92
|
+
allowedActions: {
|
|
93
|
+
update: [1],
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['title'] })).toBe(true);
|
|
98
|
+
expect(acl.can({ ...options, recordPkValue: 2, fields: ['title'] })).toBe(false);
|
|
99
|
+
expect(acl.can({ ...options, recordPkValue: 1, fields: ['body'] })).toBe(false);
|
|
100
|
+
expect(acl.can({ ...options, recordPkValue: 0, fields: ['title'] })).toBe(false);
|
|
101
|
+
expect(
|
|
102
|
+
acl.can({
|
|
103
|
+
dataSourceKey: 'main',
|
|
104
|
+
resourceName: 'posts',
|
|
105
|
+
actionName: 'update',
|
|
106
|
+
fields: ['title'],
|
|
107
|
+
}),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('allows every field when a scoped action has no field restriction', () => {
|
|
112
|
+
const payload = {
|
|
113
|
+
data: {
|
|
114
|
+
allowAll: false,
|
|
115
|
+
actionAlias: {},
|
|
116
|
+
resources: ['posts'],
|
|
117
|
+
actions: {
|
|
118
|
+
'posts:update': {
|
|
119
|
+
filter: { createdById: '{{ ctx.state.currentUser.id }}' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
strategy: { actions: [] },
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
const engine = makeEngine(payload);
|
|
126
|
+
const acl = new ACL(engine);
|
|
127
|
+
acl.setData(payload.data);
|
|
128
|
+
|
|
129
|
+
const options = {
|
|
130
|
+
dataSourceKey: 'main',
|
|
131
|
+
resourceName: 'posts',
|
|
132
|
+
actionName: 'update',
|
|
133
|
+
allowedActions: {
|
|
134
|
+
update: [1],
|
|
135
|
+
},
|
|
136
|
+
fields: ['title'],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
expect(acl.can({ ...options, recordPkValue: 1 })).toBe(true);
|
|
140
|
+
expect(acl.can({ ...options, recordPkValue: 2 })).toBe(false);
|
|
141
|
+
expect(acl.can({ ...options, allowedActions: undefined, recordPkValue: undefined })).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
74
144
|
it('reloads permissions when auth token changes', async () => {
|
|
75
145
|
const payload1 = {
|
|
76
146
|
data: {
|
|
@@ -26,26 +26,38 @@ interface MobilePopupProps {
|
|
|
26
26
|
footer?: ReactNode;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const getMobilePopupMaxHeight = () => {
|
|
30
|
+
if (typeof CSS !== 'undefined' && CSS.supports?.('height', '100dvh')) {
|
|
31
|
+
return 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return 'calc(100vh - var(--nb-mobile-page-header-height, 46px))';
|
|
35
|
+
};
|
|
36
|
+
|
|
29
37
|
export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
30
38
|
const { title, visible, onClose: closePopup, children, minHeight, className, footer } = props;
|
|
31
39
|
const { t } = useTranslation();
|
|
32
40
|
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
|
33
41
|
|
|
34
42
|
const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
|
|
43
|
+
const defaultMaxHeight = getMobilePopupMaxHeight();
|
|
35
44
|
const popupStyle = useMemo(() => {
|
|
36
45
|
return {
|
|
37
46
|
minHeight: bodyStyles?.minHeight ?? minHeight,
|
|
38
47
|
height: bodyStyles?.height,
|
|
39
|
-
maxHeight: bodyStyles?.maxHeight,
|
|
48
|
+
maxHeight: bodyStyles?.maxHeight ?? defaultMaxHeight,
|
|
40
49
|
};
|
|
41
|
-
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
|
|
50
|
+
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, defaultMaxHeight, minHeight]);
|
|
42
51
|
|
|
43
|
-
const bodyStyle = useMemo(() => {
|
|
52
|
+
const bodyStyle = useMemo<React.CSSProperties>(() => {
|
|
44
53
|
return {
|
|
45
54
|
padding: 0,
|
|
55
|
+
maxHeight: defaultMaxHeight,
|
|
56
|
+
overflowY: 'auto',
|
|
57
|
+
overflowX: 'hidden',
|
|
46
58
|
...bodyStyles,
|
|
47
59
|
};
|
|
48
|
-
}, [bodyStyles]);
|
|
60
|
+
}, [bodyStyles, defaultMaxHeight]);
|
|
49
61
|
|
|
50
62
|
const handleCloseKeyDown = useCallback(
|
|
51
63
|
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
12
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { MobilePopup } from '../MobilePopup';
|
|
14
14
|
|
|
15
15
|
vi.mock('react-i18next', () => ({
|
|
@@ -61,6 +61,47 @@ describe('MobilePopup', () => {
|
|
|
61
61
|
React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
|
|
62
62
|
>;
|
|
63
63
|
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
vi.unstubAllGlobals();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('constrains the default popup to the dynamic viewport and keeps the body scrollable', () => {
|
|
69
|
+
vi.stubGlobal('CSS', {
|
|
70
|
+
supports: vi.fn((property: string, value: string) => property === 'height' && value === '100dvh'),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
render(
|
|
74
|
+
<MobilePopup visible title="Title" onClose={vi.fn()}>
|
|
75
|
+
body
|
|
76
|
+
</MobilePopup>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const maxHeight = 'calc(100dvh - var(--nb-mobile-page-header-height, 46px))';
|
|
80
|
+
|
|
81
|
+
expect(screen.getByTestId('mobile-popup')).toHaveStyle({ maxHeight });
|
|
82
|
+
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
|
|
83
|
+
maxHeight,
|
|
84
|
+
overflowY: 'auto',
|
|
85
|
+
overflowX: 'hidden',
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('falls back to the layout viewport when dynamic viewport units are unavailable', () => {
|
|
90
|
+
vi.stubGlobal('CSS', {
|
|
91
|
+
supports: vi.fn(() => false),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<MobilePopup visible title="Title" onClose={vi.fn()}>
|
|
96
|
+
body
|
|
97
|
+
</MobilePopup>,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
|
|
101
|
+
maxHeight: 'calc(100vh - var(--nb-mobile-page-header-height, 46px))',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
64
105
|
it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
|
|
65
106
|
render(
|
|
66
107
|
<MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { css } from '@emotion/css';
|
|
11
|
-
import { Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
11
|
+
import { ConfigProvider, Dropdown, DropdownProps, Empty, Input, InputProps, Spin } from 'antd';
|
|
12
12
|
import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { useFlowEngine } from '../../provider';
|
|
14
14
|
|
|
@@ -484,13 +484,10 @@ const createSearchItem = (
|
|
|
484
484
|
if (shouldActivateSearchSubmenu) {
|
|
485
485
|
activateSearchSubmenu(searchKey);
|
|
486
486
|
}
|
|
487
|
-
if ((e.nativeEvent as
|
|
487
|
+
if ((e.nativeEvent as InputEvent).isComposing || searchHandlers.isComposing(searchKey)) {
|
|
488
488
|
searchHandlers.updateInputValue(searchKey, value);
|
|
489
489
|
return;
|
|
490
490
|
}
|
|
491
|
-
if (!value && shouldActivateSearchSubmenu) {
|
|
492
|
-
deactivateSearchSubmenu(searchKey);
|
|
493
|
-
}
|
|
494
491
|
searchHandlers.updateSearchValue(searchKey, value);
|
|
495
492
|
}}
|
|
496
493
|
onCompositionStart={(e) => {
|
|
@@ -504,11 +501,7 @@ const createSearchItem = (
|
|
|
504
501
|
e.stopPropagation();
|
|
505
502
|
const value = e.currentTarget.value;
|
|
506
503
|
if (shouldActivateSearchSubmenu) {
|
|
507
|
-
|
|
508
|
-
activateSearchSubmenu(searchKey);
|
|
509
|
-
} else {
|
|
510
|
-
deactivateSearchSubmenu(searchKey);
|
|
511
|
-
}
|
|
504
|
+
activateSearchSubmenu(searchKey);
|
|
512
505
|
}
|
|
513
506
|
searchHandlers.endComposition(searchKey, value);
|
|
514
507
|
}}
|
|
@@ -516,11 +509,21 @@ const createSearchItem = (
|
|
|
516
509
|
e.stopPropagation();
|
|
517
510
|
}}
|
|
518
511
|
onKeyDown={(e) => {
|
|
512
|
+
if (e.key === 'Escape' || e.key === 'Tab') {
|
|
513
|
+
deactivateSearchSubmenu(searchKey);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (shouldActivateSearchSubmenu) {
|
|
517
|
+
activateSearchSubmenu(searchKey);
|
|
518
|
+
}
|
|
519
519
|
e.stopPropagation();
|
|
520
520
|
}}
|
|
521
521
|
onMouseDown={(e) => {
|
|
522
522
|
// 防止菜单聚焦丢失或页面滚动
|
|
523
523
|
e.stopPropagation();
|
|
524
|
+
if (shouldActivateSearchSubmenu) {
|
|
525
|
+
activateSearchSubmenu(searchKey);
|
|
526
|
+
}
|
|
524
527
|
}}
|
|
525
528
|
size="small"
|
|
526
529
|
style={{
|
|
@@ -552,7 +555,7 @@ const KEEP_OPEN_LABEL_STYLE: React.CSSProperties = {
|
|
|
552
555
|
|
|
553
556
|
// 短暂保持打开状态的注册表(用于跨父节点快速重建时的恢复)
|
|
554
557
|
const DROPDOWN_PERSIST_TTL_MS = 350;
|
|
555
|
-
const
|
|
558
|
+
const MENU_CLOSE_DELAY = 0.3;
|
|
556
559
|
const SUBMENU_MOTION_DISABLED = {
|
|
557
560
|
motionEnter: false,
|
|
558
561
|
motionLeave: false,
|
|
@@ -561,13 +564,20 @@ const dropdownPersistRegistry: Map<string, number> = new Map();
|
|
|
561
564
|
|
|
562
565
|
const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownMenuProps }> = ({ menu, ...props }) => {
|
|
563
566
|
const engine = useFlowEngine();
|
|
567
|
+
const { getPrefixCls } = React.useContext(ConfigProvider.ConfigContext);
|
|
568
|
+
const triggerId = React.useId();
|
|
564
569
|
const [menuVisible, setMenuVisible] = useState(false);
|
|
565
570
|
const [openKeys, setOpenKeys] = useState<Set<string>>(new Set());
|
|
566
|
-
const [activeSearchKey, setActiveSearchKey] = useState<string | null>(null);
|
|
567
571
|
const [rootItems, setRootItems] = useState<Item[]>([]);
|
|
568
572
|
const [rootLoading, setRootLoading] = useState(false);
|
|
573
|
+
const activeSearchKeyRef = useRef<string | null>(null);
|
|
569
574
|
const closeByOutsideClickRef = useRef(false);
|
|
570
575
|
const skipPreserveActiveSearchRef = useRef(false);
|
|
576
|
+
const triggerOpenClassName = `nb-lazy-dropdown-trigger-${triggerId.replace(/[^a-zA-Z0-9_-]/g, '')}`;
|
|
577
|
+
const defaultOpenClassName = `${getPrefixCls('dropdown', props.prefixCls)}-open`;
|
|
578
|
+
const mergedOpenClassName = [props.openClassName ?? defaultOpenClassName, triggerOpenClassName]
|
|
579
|
+
.filter(Boolean)
|
|
580
|
+
.join(' ');
|
|
571
581
|
const dropdownMaxHeight = useNiceDropdownMaxHeight();
|
|
572
582
|
const t = engine.translate.bind(engine);
|
|
573
583
|
|
|
@@ -589,13 +599,13 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
589
599
|
|
|
590
600
|
const closeMenu = useCallback(() => {
|
|
591
601
|
setMenuVisible(false);
|
|
592
|
-
|
|
602
|
+
activeSearchKeyRef.current = null;
|
|
593
603
|
setOpenKeys(new Set());
|
|
594
604
|
clearAllSearchValues();
|
|
595
605
|
}, [clearAllSearchValues]);
|
|
596
606
|
|
|
597
607
|
const activateSearchSubmenu = useCallback((key: string) => {
|
|
598
|
-
|
|
608
|
+
activeSearchKeyRef.current = key;
|
|
599
609
|
setOpenKeys((prev) => {
|
|
600
610
|
if (prev.has(key)) return prev;
|
|
601
611
|
const next = new Set(prev);
|
|
@@ -605,11 +615,14 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
605
615
|
}, []);
|
|
606
616
|
|
|
607
617
|
const deactivateSearchSubmenu = useCallback((key: string) => {
|
|
608
|
-
|
|
618
|
+
if (activeSearchKeyRef.current === key) {
|
|
619
|
+
activeSearchKeyRef.current = null;
|
|
620
|
+
}
|
|
609
621
|
}, []);
|
|
610
622
|
|
|
611
623
|
const closeActiveSearchForPath = useCallback(
|
|
612
624
|
(keyPath: string) => {
|
|
625
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
613
626
|
if (
|
|
614
627
|
!activeSearchKey ||
|
|
615
628
|
keyPath === activeSearchKey ||
|
|
@@ -621,25 +634,26 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
621
634
|
|
|
622
635
|
skipPreserveActiveSearchRef.current = true;
|
|
623
636
|
clearSearchValue(activeSearchKey);
|
|
624
|
-
|
|
637
|
+
activeSearchKeyRef.current = null;
|
|
625
638
|
setOpenKeys((prev) => {
|
|
626
639
|
const next = new Set(prev);
|
|
627
640
|
next.delete(activeSearchKey);
|
|
628
641
|
return next;
|
|
629
642
|
});
|
|
630
643
|
},
|
|
631
|
-
[
|
|
644
|
+
[clearSearchValue],
|
|
632
645
|
);
|
|
633
646
|
|
|
634
647
|
const handleMenuOpenChange = useCallback(
|
|
635
648
|
(nextOpenKeys: string[]) => {
|
|
636
649
|
let normalized = normalizeOpenKeys(nextOpenKeys);
|
|
637
|
-
|
|
638
|
-
|
|
650
|
+
const activeSearchKey = activeSearchKeyRef.current;
|
|
651
|
+
if (activeSearchKey && !normalized.includes(activeSearchKey)) {
|
|
652
|
+
if (skipPreserveActiveSearchRef.current) {
|
|
639
653
|
clearSearchValue(activeSearchKey);
|
|
640
|
-
|
|
654
|
+
activeSearchKeyRef.current = null;
|
|
641
655
|
} else {
|
|
642
|
-
normalized =
|
|
656
|
+
normalized = Array.from(openKeys);
|
|
643
657
|
}
|
|
644
658
|
}
|
|
645
659
|
|
|
@@ -658,7 +672,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
658
672
|
dropdownMenuProps.onOpenChange?.(normalized);
|
|
659
673
|
skipPreserveActiveSearchRef.current = false;
|
|
660
674
|
},
|
|
661
|
-
[
|
|
675
|
+
[clearSearchValue, dropdownMenuProps, openKeys, shouldPreventClose],
|
|
662
676
|
);
|
|
663
677
|
|
|
664
678
|
useEffect(() => {
|
|
@@ -666,7 +680,9 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
666
680
|
|
|
667
681
|
const markOutsideClick = (event: MouseEvent | PointerEvent) => {
|
|
668
682
|
const target = event.target as HTMLElement | null;
|
|
669
|
-
const
|
|
683
|
+
const isInsidePopup = target?.closest('.ant-dropdown, .ant-dropdown-menu, .ant-dropdown-menu-submenu-popup');
|
|
684
|
+
const isInsideCurrentTrigger = target?.closest(`.${triggerOpenClassName}`);
|
|
685
|
+
const isOutside = !isInsidePopup && !isInsideCurrentTrigger;
|
|
670
686
|
closeByOutsideClickRef.current = isOutside;
|
|
671
687
|
if (isOutside) {
|
|
672
688
|
closeMenu();
|
|
@@ -679,7 +695,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
679
695
|
document.removeEventListener('pointerdown', markOutsideClick, true);
|
|
680
696
|
document.removeEventListener('mousedown', markOutsideClick, true);
|
|
681
697
|
};
|
|
682
|
-
}, [closeMenu, menuVisible]);
|
|
698
|
+
}, [closeMenu, menuVisible, triggerOpenClassName]);
|
|
683
699
|
|
|
684
700
|
// 在挂载时,若存在 persistKey 且仍在持久期内,则尝试恢复打开状态
|
|
685
701
|
useEffect(() => {
|
|
@@ -957,13 +973,15 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
957
973
|
{...props}
|
|
958
974
|
open={menuVisible}
|
|
959
975
|
destroyPopupOnHide
|
|
976
|
+
mouseLeaveDelay={props.mouseLeaveDelay ?? MENU_CLOSE_DELAY}
|
|
977
|
+
openClassName={mergedOpenClassName}
|
|
960
978
|
overlayClassName={overlayClassName}
|
|
961
979
|
placement="bottomLeft"
|
|
962
980
|
menu={{
|
|
963
981
|
...dropdownMenuProps,
|
|
964
982
|
openKeys: Array.from(openKeys),
|
|
965
983
|
items: items,
|
|
966
|
-
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ??
|
|
984
|
+
subMenuCloseDelay: dropdownMenuProps.subMenuCloseDelay ?? MENU_CLOSE_DELAY,
|
|
967
985
|
motion: dropdownMenuProps.motion ?? SUBMENU_MOTION_DISABLED,
|
|
968
986
|
onClick: () => {},
|
|
969
987
|
onOpenChange: handleMenuOpenChange,
|
|
@@ -974,7 +992,7 @@ const LazyDropdown: React.FC<Omit<DropdownProps, 'menu'> & { menu: LazyDropdownM
|
|
|
974
992
|
},
|
|
975
993
|
}}
|
|
976
994
|
onOpenChange={(visible, info) => {
|
|
977
|
-
if (!visible &&
|
|
995
|
+
if (!visible && activeSearchKeyRef.current && info?.source === 'trigger' && !closeByOutsideClickRef.current) {
|
|
978
996
|
return;
|
|
979
997
|
}
|
|
980
998
|
|
|
@@ -411,7 +411,7 @@ describe('transformItems - searchable flags', () => {
|
|
|
411
411
|
await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
|
|
412
412
|
});
|
|
413
413
|
|
|
414
|
-
it('
|
|
414
|
+
it('keeps searchable submenu open while interacting with its input', async () => {
|
|
415
415
|
const engine = new FlowEngine();
|
|
416
416
|
await engine.flowSettings.forceEnable();
|
|
417
417
|
class Parent extends FlowModel {}
|
|
@@ -451,7 +451,11 @@ describe('transformItems - searchable flags', () => {
|
|
|
451
451
|
await user.click(screen.getByRole('textbox'));
|
|
452
452
|
fireEvent.mouseLeave(screen.getByText('Fields'));
|
|
453
453
|
|
|
454
|
-
await
|
|
454
|
+
await act(async () => {
|
|
455
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(screen.getByText('Field 1')).toBeInTheDocument();
|
|
455
459
|
});
|
|
456
460
|
|
|
457
461
|
it('closes active searchable submenu after outside click', async () => {
|
|
@@ -499,6 +503,85 @@ describe('transformItems - searchable flags', () => {
|
|
|
499
503
|
await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
|
|
500
504
|
});
|
|
501
505
|
|
|
506
|
+
it('keeps the dropdown open when clicking its already-hovered trigger', async () => {
|
|
507
|
+
const engine = new FlowEngine();
|
|
508
|
+
await engine.flowSettings.forceEnable();
|
|
509
|
+
class Parent extends FlowModel {}
|
|
510
|
+
engine.registerModels({ Parent });
|
|
511
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
512
|
+
const user = userEvent.setup();
|
|
513
|
+
|
|
514
|
+
render(
|
|
515
|
+
<FlowEngineProvider engine={engine}>
|
|
516
|
+
<ConfigProvider>
|
|
517
|
+
<App>
|
|
518
|
+
<AddSubModelButton
|
|
519
|
+
model={parent}
|
|
520
|
+
subModelKey="items"
|
|
521
|
+
items={[
|
|
522
|
+
{
|
|
523
|
+
key: 'child',
|
|
524
|
+
label: 'Child',
|
|
525
|
+
createModelOptions: { use: 'Parent' },
|
|
526
|
+
},
|
|
527
|
+
]}
|
|
528
|
+
>
|
|
529
|
+
Open
|
|
530
|
+
</AddSubModelButton>
|
|
531
|
+
</App>
|
|
532
|
+
</ConfigProvider>
|
|
533
|
+
</FlowEngineProvider>,
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const trigger = screen.getByText('Open');
|
|
537
|
+
await user.hover(trigger);
|
|
538
|
+
await waitFor(() => expect(screen.getByText('Child')).toBeInTheDocument());
|
|
539
|
+
expect(trigger).toHaveClass('ant-dropdown-open');
|
|
540
|
+
|
|
541
|
+
await user.click(trigger);
|
|
542
|
+
|
|
543
|
+
expect(screen.getByText('Child')).toBeInTheDocument();
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('closes the dropdown when clicking another dropdown trigger', async () => {
|
|
547
|
+
const engine = new FlowEngine();
|
|
548
|
+
await engine.flowSettings.forceEnable();
|
|
549
|
+
class Parent extends FlowModel {}
|
|
550
|
+
engine.registerModels({ Parent });
|
|
551
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
552
|
+
const user = userEvent.setup();
|
|
553
|
+
|
|
554
|
+
render(
|
|
555
|
+
<FlowEngineProvider engine={engine}>
|
|
556
|
+
<ConfigProvider>
|
|
557
|
+
<App>
|
|
558
|
+
<AddSubModelButton
|
|
559
|
+
model={parent}
|
|
560
|
+
subModelKey="firstItems"
|
|
561
|
+
items={[{ key: 'first-child', label: 'First child', createModelOptions: { use: 'Parent' } }]}
|
|
562
|
+
>
|
|
563
|
+
Open first
|
|
564
|
+
</AddSubModelButton>
|
|
565
|
+
<AddSubModelButton
|
|
566
|
+
model={parent}
|
|
567
|
+
subModelKey="secondItems"
|
|
568
|
+
items={[{ key: 'second-child', label: 'Second child', createModelOptions: { use: 'Parent' } }]}
|
|
569
|
+
>
|
|
570
|
+
Open second
|
|
571
|
+
</AddSubModelButton>
|
|
572
|
+
</App>
|
|
573
|
+
</ConfigProvider>
|
|
574
|
+
</FlowEngineProvider>,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
await user.hover(screen.getByText('Open first'));
|
|
578
|
+
await waitFor(() => expect(screen.getByText('First child')).toBeInTheDocument());
|
|
579
|
+
|
|
580
|
+
fireEvent.pointerDown(screen.getByText('Open second'));
|
|
581
|
+
|
|
582
|
+
await waitFor(() => expect(screen.queryByText('First child')).not.toBeInTheDocument());
|
|
583
|
+
});
|
|
584
|
+
|
|
502
585
|
it('switches away from active searchable submenu and resets its input', async () => {
|
|
503
586
|
const engine = new FlowEngine();
|
|
504
587
|
await engine.flowSettings.forceEnable();
|
|
@@ -0,0 +1,21 @@
|
|
|
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 } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import { getFlowEngineTranslation } from '../index';
|
|
13
|
+
|
|
14
|
+
describe('flow engine locale', () => {
|
|
15
|
+
it('translates the default secondary confirmation text into Chinese', () => {
|
|
16
|
+
expect(getFlowEngineTranslation('Please Confirm', 'zh-CN')).toBe('请确认');
|
|
17
|
+
expect(getFlowEngineTranslation('Are you sure you want to perform the action?', 'zh-CN')).toBe(
|
|
18
|
+
'确定要执行此操作吗?',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
package/src/locale/en-US.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"Add": "Add",
|
|
3
|
+
"Are you sure you want to perform the action?": "Are you sure you want to perform the action?",
|
|
3
4
|
"Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
|
|
4
5
|
"Are you sure to convert this template block to copy mode?": "Are you sure you want to convert this template block to copy mode?",
|
|
5
6
|
"Array index out of bounds": "Array index {{index}} out of bounds for '{{subKey}}'",
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"Other blocks": "Other blocks",
|
|
54
55
|
"Parent not found, cannot replace block": "Parent not found, cannot replace block",
|
|
55
56
|
"Previous step": "Previous step",
|
|
57
|
+
"Please Confirm": "Please Confirm",
|
|
56
58
|
"Replace current block with template?": "Replace current block with template?",
|
|
57
59
|
"Replaced with template block": "Replaced with template block",
|
|
58
60
|
"Render failed": "Render failed",
|
package/src/locale/zh-CN.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"Add": "添加",
|
|
3
|
+
"Are you sure you want to perform the action?": "确定要执行此操作吗?",
|
|
3
4
|
"Are you sure you want to delete this item? This action cannot be undone.": "确定要删除此项吗?此操作不可撤销。",
|
|
4
5
|
"Are you sure to convert this template block to copy mode?": "确定将该模板区块转换为复制模式吗?",
|
|
5
6
|
"Array index out of bounds": "数组索引 {{index}} 超出 '{{subKey}}' 的边界",
|
|
@@ -59,6 +60,7 @@
|
|
|
59
60
|
"OK": "确定",
|
|
60
61
|
"Other blocks": "其他区块",
|
|
61
62
|
"Previous step": "上一步",
|
|
63
|
+
"Please Confirm": "请确认",
|
|
62
64
|
"Render failed": "渲染失败",
|
|
63
65
|
"Response record": "响应结果记录",
|
|
64
66
|
"Step configuration": "步骤配置",
|