@nocobase/flow-engine 2.2.0-beta.9 → 2.2.1
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/FlowContextSelector.js +62 -13
- package/lib/components/FormItem.js +11 -7
- package/lib/components/MobilePopup.js +39 -10
- package/lib/components/MobilePopup.style.js +11 -1
- package/lib/components/subModel/LazyDropdown.js +62 -33
- package/lib/components/variables/VariableHybridInput.d.ts +9 -0
- package/lib/components/variables/VariableHybridInput.js +146 -17
- package/lib/components/variables/VariableInput.js +19 -7
- package/lib/components/variables/VariableTag.js +48 -36
- package/lib/components/variables/types.d.ts +21 -0
- package/lib/flowContext.d.ts +12 -1
- package/lib/flowContext.js +47 -6
- package/lib/flowEngine.js +6 -0
- package/lib/flowI18n.js +3 -3
- 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/lib/resources/flowResource.js +1 -0
- package/lib/types.d.ts +3 -1
- package/lib/types.js +1 -0
- package/lib/utils/associationObjectVariable.d.ts +10 -0
- package/lib/utils/associationObjectVariable.js +10 -7
- package/lib/utils/dateVariable.d.ts +22 -0
- package/lib/utils/dateVariable.js +123 -16
- package/lib/utils/dirtyAwareApiClient.d.ts +1 -0
- package/lib/utils/dirtyAwareApiClient.js +280 -13
- package/lib/utils/index.d.ts +3 -3
- package/lib/utils/index.js +8 -0
- package/lib/utils/loadedPageCache.d.ts +1 -0
- package/lib/utils/loadedPageCache.js +6 -0
- package/lib/utils/params-resolvers.d.ts +3 -0
- package/lib/utils/params-resolvers.js +10 -0
- package/lib/utils/variablesParams.js +5 -0
- package/lib/views/createViewMeta.d.ts +1 -0
- package/lib/views/createViewMeta.js +53 -22
- package/package.json +4 -4
- package/src/__tests__/createViewMeta.popup.test.ts +84 -1
- package/src/__tests__/flowContext.test.ts +8 -0
- package/src/__tests__/flowI18n.test.ts +11 -0
- package/src/__tests__/objectVariable.test.ts +6 -1
- package/src/__tests__/runjsFormSubmit.test.ts +138 -0
- package/src/__tests__/viewScopedFlowEngine.test.ts +72 -6
- package/src/acl/Acl.tsx +36 -1
- package/src/acl/__tests__/Acl.test.tsx +70 -0
- package/src/components/FlowContextSelector.tsx +73 -12
- package/src/components/FormItem.tsx +12 -7
- package/src/components/MobilePopup.style.ts +12 -1
- package/src/components/MobilePopup.tsx +42 -10
- package/src/components/__tests__/FormItem.test.tsx +17 -2
- package/src/components/__tests__/MobilePopup.test.tsx +150 -0
- package/src/components/subModel/LazyDropdown.tsx +71 -38
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +85 -2
- package/src/components/subModel/__tests__/LazyDropdown.test.tsx +202 -0
- package/src/components/variables/VariableHybridInput.tsx +185 -14
- package/src/components/variables/VariableInput.tsx +32 -7
- package/src/components/variables/VariableTag.tsx +51 -37
- package/src/components/variables/__tests__/FlowContextSelector.test.tsx +95 -3
- package/src/components/variables/__tests__/VariableHybridInput.test.tsx +212 -0
- package/src/components/variables/__tests__/VariableInput.test.tsx +202 -6
- package/src/components/variables/__tests__/VariableTag.test.tsx +80 -0
- package/src/components/variables/types.ts +21 -0
- package/src/flowContext.ts +79 -6
- package/src/flowEngine.ts +6 -0
- package/src/flowI18n.ts +8 -3
- 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/src/resources/__tests__/flowResource.test.ts +3 -0
- package/src/resources/flowResource.ts +1 -0
- package/src/types.ts +2 -0
- package/src/utils/__tests__/dateVariable.test.ts +57 -4
- package/src/utils/__tests__/dirtyAwareApiClient.test.ts +321 -0
- package/src/utils/__tests__/variablesParams.test.ts +28 -1
- package/src/utils/associationObjectVariable.ts +9 -6
- package/src/utils/dateVariable.ts +145 -18
- package/src/utils/dirtyAwareApiClient.ts +348 -13
- package/src/utils/index.ts +17 -2
- package/src/utils/loadedPageCache.ts +7 -0
- package/src/utils/params-resolvers.ts +12 -0
- package/src/utils/variablesParams.ts +10 -0
- package/src/views/createViewMeta.ts +52 -18
|
@@ -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: {
|
|
@@ -40,6 +40,18 @@ type SelectedPathInfo = {
|
|
|
40
40
|
meta?: ContextSelectorItem['meta'];
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
type MetaNodeTooltipOptions = { tooltip?: React.ReactNode };
|
|
44
|
+
|
|
45
|
+
function getMetaNodeTooltip(meta?: ContextSelectorItem['meta']): React.ReactNode {
|
|
46
|
+
if (!meta) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const metaWithTooltip = meta as ContextSelectorItem['meta'] & MetaNodeTooltipOptions;
|
|
51
|
+
const options = meta.options as MetaNodeTooltipOptions | undefined;
|
|
52
|
+
return metaWithTooltip.tooltip ?? options?.tooltip;
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
const normalizePath = (path: unknown): string[] | undefined => {
|
|
44
56
|
if (!Array.isArray(path)) {
|
|
45
57
|
return undefined;
|
|
@@ -85,6 +97,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
85
97
|
value,
|
|
86
98
|
onChange,
|
|
87
99
|
children,
|
|
100
|
+
active,
|
|
88
101
|
metaTree,
|
|
89
102
|
showSearch = false,
|
|
90
103
|
parseValueToPath: customParseValueToPath = parseValueToPath,
|
|
@@ -92,6 +105,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
92
105
|
open,
|
|
93
106
|
onlyLeafSelectable = false,
|
|
94
107
|
ignoreFieldNames,
|
|
108
|
+
dropdownFooter,
|
|
95
109
|
...cascaderProps
|
|
96
110
|
}) => {
|
|
97
111
|
const { token } = theme.useToken();
|
|
@@ -119,17 +133,28 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
119
133
|
|
|
120
134
|
// 文本国际化:仅当 label 为字符串时进行翻译
|
|
121
135
|
const baseLabel = typeof o.label === 'string' ? flowCtx.t(o.label) : o.label;
|
|
136
|
+
const labelText = typeof baseLabel === 'string' ? baseLabel : String(o.value);
|
|
137
|
+
const tooltip = getMetaNodeTooltip(meta);
|
|
138
|
+
const tooltipTitle = disabled
|
|
139
|
+
? disabledReason || tooltip || flowCtx.t('This variable is not available')
|
|
140
|
+
: tooltip;
|
|
122
141
|
|
|
123
|
-
const label =
|
|
142
|
+
const label = tooltipTitle ? (
|
|
124
143
|
<span>
|
|
125
144
|
{baseLabel}
|
|
126
145
|
<Tooltip
|
|
127
|
-
title={
|
|
128
|
-
placement="
|
|
129
|
-
|
|
146
|
+
title={typeof tooltipTitle === 'string' ? flowCtx.t(tooltipTitle) : tooltipTitle}
|
|
147
|
+
placement="top"
|
|
148
|
+
classNames={{ root: 'flow-variable-tip' }}
|
|
130
149
|
destroyTooltipOnHide
|
|
131
150
|
>
|
|
132
|
-
<QuestionCircleOutlined
|
|
151
|
+
<QuestionCircleOutlined
|
|
152
|
+
aria-label={`${labelText} tooltip`}
|
|
153
|
+
style={{
|
|
154
|
+
marginLeft: token.marginXXS,
|
|
155
|
+
color: disabled ? token.colorTextDisabled : token.colorTextDescription,
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
133
158
|
</Tooltip>
|
|
134
159
|
</span>
|
|
135
160
|
) : (
|
|
@@ -144,7 +169,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
144
169
|
};
|
|
145
170
|
});
|
|
146
171
|
},
|
|
147
|
-
[flowCtx],
|
|
172
|
+
[flowCtx, token.colorTextDescription, token.colorTextDisabled, token.marginXXS],
|
|
148
173
|
);
|
|
149
174
|
|
|
150
175
|
// 用于强制重新渲染的状态
|
|
@@ -265,13 +290,13 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
265
290
|
|
|
266
291
|
// 默认按钮组件
|
|
267
292
|
const defaultChildren = useMemo(() => {
|
|
268
|
-
const hasSelected = currentPath && currentPath.length > 0;
|
|
293
|
+
const hasSelected = active ?? Boolean(currentPath && currentPath.length > 0);
|
|
269
294
|
return (
|
|
270
|
-
<Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle}>
|
|
295
|
+
<Button type={hasSelected ? 'primary' : 'default'} style={defaultButtonStyle} disabled={cascaderProps.disabled}>
|
|
271
296
|
x
|
|
272
297
|
</Button>
|
|
273
298
|
);
|
|
274
|
-
}, [currentPath]);
|
|
299
|
+
}, [active, cascaderProps.disabled, currentPath]);
|
|
275
300
|
|
|
276
301
|
// 处理选择变化事件
|
|
277
302
|
const handleChange = useCallback(
|
|
@@ -286,6 +311,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
286
311
|
const path = selectedValues.map(String);
|
|
287
312
|
const pathString = path.join('.');
|
|
288
313
|
const isLeaf = lastOption?.isLeaf;
|
|
314
|
+
const isSelectable = lastOption?.meta?.selectable !== false;
|
|
289
315
|
const now = Date.now();
|
|
290
316
|
|
|
291
317
|
// 使用自定义格式化函数或默认函数
|
|
@@ -300,6 +326,10 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
300
326
|
}
|
|
301
327
|
|
|
302
328
|
if (isLeaf) {
|
|
329
|
+
if (!isSelectable) {
|
|
330
|
+
setTempSelectedPath(path);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
303
333
|
onChange?.(formattedValue, lastOption?.meta);
|
|
304
334
|
// 选中叶子节点后,可清空内部临时路径(外部 value 将驱动级联)
|
|
305
335
|
setTempSelectedPath([]);
|
|
@@ -308,7 +338,8 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
308
338
|
|
|
309
339
|
// 非叶子节点:检查双击
|
|
310
340
|
const lastSelected = lastSelectedRef.current;
|
|
311
|
-
const isDoubleClick =
|
|
341
|
+
const isDoubleClick =
|
|
342
|
+
isSelectable && !onlyLeafSelectable && lastSelected?.path === pathString && now - lastSelected.time < 300;
|
|
312
343
|
|
|
313
344
|
if (isDoubleClick) {
|
|
314
345
|
// 双击:选中非叶子节点
|
|
@@ -360,12 +391,41 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
360
391
|
[cascaderOnDropdownVisibleChange, open],
|
|
361
392
|
);
|
|
362
393
|
|
|
394
|
+
// Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
|
|
395
|
+
// non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
|
|
396
|
+
// their own node, or pass `null` to hide it.
|
|
397
|
+
const footerNode = useMemo(() => {
|
|
398
|
+
if (dropdownFooter !== undefined) {
|
|
399
|
+
return dropdownFooter;
|
|
400
|
+
}
|
|
401
|
+
if (onlyLeafSelectable) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
return (
|
|
405
|
+
<div
|
|
406
|
+
className={css`
|
|
407
|
+
padding: 6px 12px;
|
|
408
|
+
color: ${token.colorTextDescription};
|
|
409
|
+
border-top: 1px solid ${token.colorSplit};
|
|
410
|
+
font-size: ${token.fontSizeSM}px;
|
|
411
|
+
`}
|
|
412
|
+
>
|
|
413
|
+
{flowCtx.t('Double click to choose entire object')}
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
|
|
417
|
+
|
|
363
418
|
const renderDropdown = useCallback(
|
|
364
419
|
(menu: React.ReactElement) => {
|
|
365
420
|
const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
|
|
366
421
|
const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
|
|
367
422
|
if (!isSearchEnabled || children === null) {
|
|
368
|
-
return
|
|
423
|
+
return (
|
|
424
|
+
<>
|
|
425
|
+
{cascaderMenu}
|
|
426
|
+
{footerNode}
|
|
427
|
+
</>
|
|
428
|
+
);
|
|
369
429
|
}
|
|
370
430
|
|
|
371
431
|
return (
|
|
@@ -381,10 +441,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
|
|
381
441
|
/>
|
|
382
442
|
</div>
|
|
383
443
|
{cascaderMenu}
|
|
444
|
+
{footerNode}
|
|
384
445
|
</>
|
|
385
446
|
);
|
|
386
447
|
},
|
|
387
|
-
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
|
|
448
|
+
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode],
|
|
388
449
|
);
|
|
389
450
|
|
|
390
451
|
const inlinePlaceholder =
|
|
@@ -54,15 +54,20 @@ const formItemPropKeys: (keyof ExtendedFormItemProps)[] = [
|
|
|
54
54
|
'showLabel',
|
|
55
55
|
];
|
|
56
56
|
|
|
57
|
+
const modelInternalPropKeys = ['globalSort'];
|
|
58
|
+
|
|
57
59
|
export const FormItem = ({
|
|
58
60
|
children,
|
|
59
61
|
showLabel = true,
|
|
60
62
|
labelWidth,
|
|
61
63
|
...rest
|
|
62
64
|
}: ExtendedFormItemProps & ChildExtraProps) => {
|
|
65
|
+
const forwardedRest = Object.fromEntries(
|
|
66
|
+
Object.entries(rest).filter(([key]) => !modelInternalPropKeys.includes(key)),
|
|
67
|
+
) as ExtendedFormItemProps & ChildExtraProps;
|
|
63
68
|
// 过滤掉 Form.Item 专用 props,只保留要传给子组件的
|
|
64
69
|
const childProps = Object.fromEntries(
|
|
65
|
-
Object.entries(
|
|
70
|
+
Object.entries(forwardedRest).filter(([key]) => !formItemPropKeys.includes(key as keyof ExtendedFormItemProps)),
|
|
66
71
|
);
|
|
67
72
|
|
|
68
73
|
const processedChildren =
|
|
@@ -74,7 +79,7 @@ export const FormItem = ({
|
|
|
74
79
|
}
|
|
75
80
|
return child;
|
|
76
81
|
});
|
|
77
|
-
const { label, labelWrap, colon = true, layout } =
|
|
82
|
+
const { label, labelWrap, colon = true, layout } = forwardedRest;
|
|
78
83
|
const effectiveLabelWrap = !layout || layout === 'vertical' ? true : labelWrap;
|
|
79
84
|
const labelColStyle =
|
|
80
85
|
layout === 'vertical' ? { width: labelWidth, ...verticalFormItemLabelStyle } : { width: labelWidth };
|
|
@@ -122,17 +127,17 @@ export const FormItem = ({
|
|
|
122
127
|
};
|
|
123
128
|
return (
|
|
124
129
|
<Form.Item
|
|
125
|
-
{...
|
|
126
|
-
style={{ ...formItemStyle, ...
|
|
130
|
+
{...forwardedRest}
|
|
131
|
+
style={{ ...formItemStyle, ...forwardedRest.style }}
|
|
127
132
|
labelCol={{ style: labelColStyle }}
|
|
128
133
|
layout={layout}
|
|
129
134
|
label={renderLabel()}
|
|
130
135
|
colon={false}
|
|
131
|
-
extra={
|
|
136
|
+
extra={forwardedRest.extra && <span style={{ whiteSpace: 'pre-wrap' }}>{forwardedRest.extra}</span>}
|
|
132
137
|
tooltip={
|
|
133
|
-
|
|
138
|
+
forwardedRest.tooltip &&
|
|
134
139
|
({
|
|
135
|
-
title:
|
|
140
|
+
title: forwardedRest.tooltip,
|
|
136
141
|
overlayInnerStyle: { whiteSpace: 'pre-line' },
|
|
137
142
|
} as TooltipProps)
|
|
138
143
|
}
|
|
@@ -160,7 +160,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
160
160
|
borderBottom: `1px solid ${token.colorSplit}`,
|
|
161
161
|
position: 'sticky',
|
|
162
162
|
top: 0,
|
|
163
|
-
backgroundColor:
|
|
163
|
+
backgroundColor: token.colorBgContainer,
|
|
164
164
|
zIndex: 1000,
|
|
165
165
|
|
|
166
166
|
// to match the button named 'Add block'
|
|
@@ -172,12 +172,23 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
|
|
172
172
|
'.nb-mobile-action-drawer-placeholder': {
|
|
173
173
|
display: 'inline-block',
|
|
174
174
|
padding: 12,
|
|
175
|
+
flex: '0 0 auto',
|
|
175
176
|
visibility: 'hidden',
|
|
176
177
|
},
|
|
177
178
|
|
|
179
|
+
'.nb-mobile-action-drawer-title': {
|
|
180
|
+
flex: '1 1 auto',
|
|
181
|
+
minWidth: 0,
|
|
182
|
+
overflow: 'hidden',
|
|
183
|
+
textAlign: 'center',
|
|
184
|
+
textOverflow: 'ellipsis',
|
|
185
|
+
whiteSpace: 'nowrap',
|
|
186
|
+
},
|
|
187
|
+
|
|
178
188
|
'.nb-mobile-action-drawer-close-icon': {
|
|
179
189
|
display: 'inline-block',
|
|
180
190
|
padding: 12,
|
|
191
|
+
flex: '0 0 auto',
|
|
181
192
|
cursor: 'pointer',
|
|
182
193
|
},
|
|
183
194
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ConfigProvider } from 'antd';
|
|
11
|
-
import React, { FC, ReactNode, useMemo } from 'react';
|
|
11
|
+
import React, { FC, ReactNode, useCallback, useMemo } from 'react';
|
|
12
12
|
import { useMobileActionDrawerStyle } from './MobilePopup.style';
|
|
13
13
|
import { useTranslation } from 'react-i18next';
|
|
14
14
|
import { lazy } from '../lazy-helper';
|
|
@@ -26,16 +26,50 @@ 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
|
-
const
|
|
42
|
+
const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
|
|
43
|
+
const defaultMaxHeight = getMobilePopupMaxHeight();
|
|
44
|
+
const popupStyle = useMemo(() => {
|
|
35
45
|
return {
|
|
36
|
-
minHeight,
|
|
46
|
+
minHeight: bodyStyles?.minHeight ?? minHeight,
|
|
47
|
+
height: bodyStyles?.height,
|
|
48
|
+
maxHeight: bodyStyles?.maxHeight ?? defaultMaxHeight,
|
|
37
49
|
};
|
|
38
|
-
}, [minHeight]);
|
|
50
|
+
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, defaultMaxHeight, minHeight]);
|
|
51
|
+
|
|
52
|
+
const bodyStyle = useMemo<React.CSSProperties>(() => {
|
|
53
|
+
return {
|
|
54
|
+
padding: 0,
|
|
55
|
+
maxHeight: defaultMaxHeight,
|
|
56
|
+
overflowY: 'auto',
|
|
57
|
+
overflowX: 'hidden',
|
|
58
|
+
...bodyStyles,
|
|
59
|
+
};
|
|
60
|
+
}, [bodyStyles, defaultMaxHeight]);
|
|
61
|
+
|
|
62
|
+
const handleCloseKeyDown = useCallback(
|
|
63
|
+
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
|
64
|
+
if (event.key !== 'Enter' && event.key !== ' ') {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
closePopup();
|
|
70
|
+
},
|
|
71
|
+
[closePopup],
|
|
72
|
+
);
|
|
39
73
|
|
|
40
74
|
const theme = useMemo(() => {
|
|
41
75
|
return {
|
|
@@ -57,11 +91,8 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
|
57
91
|
onClose={closePopup}
|
|
58
92
|
onMaskClick={closePopup}
|
|
59
93
|
bodyClassName="nb-mobile-action-drawer-body"
|
|
60
|
-
bodyStyle={
|
|
61
|
-
|
|
62
|
-
}}
|
|
63
|
-
maskStyle={style}
|
|
64
|
-
style={style}
|
|
94
|
+
bodyStyle={bodyStyle}
|
|
95
|
+
style={popupStyle}
|
|
65
96
|
destroyOnClose
|
|
66
97
|
>
|
|
67
98
|
<div className="nb-mobile-action-drawer-header">
|
|
@@ -69,13 +100,14 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
|
|
69
100
|
<span className="nb-mobile-action-drawer-placeholder">
|
|
70
101
|
<CloseOutline />
|
|
71
102
|
</span>
|
|
72
|
-
<span>{title}</span>
|
|
103
|
+
<span className="nb-mobile-action-drawer-title">{title}</span>
|
|
73
104
|
<span
|
|
74
105
|
className="nb-mobile-action-drawer-close-icon"
|
|
75
106
|
onClick={closePopup}
|
|
76
107
|
role="button"
|
|
77
108
|
tabIndex={0}
|
|
78
109
|
aria-label={t('Close')}
|
|
110
|
+
onKeyDown={handleCloseKeyDown}
|
|
79
111
|
>
|
|
80
112
|
<CloseOutline />
|
|
81
113
|
</span>
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import
|
|
10
|
+
import { render } from '@testing-library/react';
|
|
11
|
+
import React from 'react';
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { FormItem, formItemStyle, verticalFormItemLabelStyle } from '../FormItem';
|
|
12
14
|
|
|
13
15
|
describe('FormItem', () => {
|
|
14
16
|
it('keeps vertical label-to-value spacing consistent with v1', () => {
|
|
@@ -22,4 +24,17 @@ describe('FormItem', () => {
|
|
|
22
24
|
marginBottom: 12,
|
|
23
25
|
});
|
|
24
26
|
});
|
|
27
|
+
|
|
28
|
+
it('does not forward model-internal globalSort props to field children', () => {
|
|
29
|
+
const Field = vi.fn(() => <input aria-label="field" />);
|
|
30
|
+
|
|
31
|
+
render(
|
|
32
|
+
<FormItem globalSort={['title']} placeholder="Title">
|
|
33
|
+
<Field />
|
|
34
|
+
</FormItem>,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(Field).toHaveBeenCalledWith(expect.not.objectContaining({ globalSort: expect.anything() }), {});
|
|
38
|
+
expect(Field).toHaveBeenCalledWith(expect.objectContaining({ placeholder: 'Title' }), {});
|
|
39
|
+
});
|
|
25
40
|
});
|
|
@@ -0,0 +1,150 @@
|
|
|
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 React from 'react';
|
|
11
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { MobilePopup } from '../MobilePopup';
|
|
14
|
+
|
|
15
|
+
vi.mock('react-i18next', () => ({
|
|
16
|
+
useTranslation: () => ({
|
|
17
|
+
t: (key: string) => key,
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../MobilePopup.style', () => ({
|
|
22
|
+
useMobileActionDrawerStyle: () => ({
|
|
23
|
+
componentCls: 'nb-mobile-action-drawer',
|
|
24
|
+
hashId: 'hash',
|
|
25
|
+
}),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../../lazy-helper', () => ({
|
|
29
|
+
lazy: (_loader: unknown, name: string) => {
|
|
30
|
+
if (name === 'Popup') {
|
|
31
|
+
const Popup = ({
|
|
32
|
+
children,
|
|
33
|
+
bodyStyle,
|
|
34
|
+
maskStyle,
|
|
35
|
+
style,
|
|
36
|
+
}: {
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
bodyStyle?: React.CSSProperties;
|
|
39
|
+
maskStyle?: React.CSSProperties;
|
|
40
|
+
style?: React.CSSProperties;
|
|
41
|
+
}) => (
|
|
42
|
+
<div data-testid="mobile-popup" style={style}>
|
|
43
|
+
<div data-testid="mobile-popup-mask" style={maskStyle} />
|
|
44
|
+
<div data-testid="mobile-popup-body" style={bodyStyle}>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { Popup };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
CloseOutline: () => <span data-testid="close-outline" />,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
describe('MobilePopup', () => {
|
|
60
|
+
const MobilePopupWithDrawerStyles = MobilePopup as React.ComponentType<
|
|
61
|
+
React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
|
|
62
|
+
>;
|
|
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
|
+
|
|
105
|
+
it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
|
|
106
|
+
render(
|
|
107
|
+
<MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
|
|
108
|
+
body
|
|
109
|
+
</MobilePopupWithDrawerStyles>,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(screen.getByTestId('mobile-popup')).toHaveStyle({
|
|
113
|
+
maxHeight: '50vh',
|
|
114
|
+
});
|
|
115
|
+
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
|
|
116
|
+
maxHeight: '50vh',
|
|
117
|
+
});
|
|
118
|
+
expect(screen.getByTestId('mobile-popup-mask')).not.toHaveStyle({
|
|
119
|
+
maxHeight: '50vh',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('closes from the header close icon with Enter or Space', () => {
|
|
124
|
+
const onClose = vi.fn();
|
|
125
|
+
render(
|
|
126
|
+
<MobilePopup visible title="Title" onClose={onClose}>
|
|
127
|
+
body
|
|
128
|
+
</MobilePopup>,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const closeButton = screen.getByRole('button', { name: 'Close' });
|
|
132
|
+
fireEvent.keyDown(closeButton, { key: 'Enter' });
|
|
133
|
+
fireEvent.keyDown(closeButton, { key: ' ' });
|
|
134
|
+
|
|
135
|
+
expect(onClose).toHaveBeenCalledTimes(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps long header titles in a constrained title element separate from the close button', () => {
|
|
139
|
+
const longTitle = 'A very long table column title that should not push the close button outside the drawer';
|
|
140
|
+
|
|
141
|
+
render(
|
|
142
|
+
<MobilePopup visible title={longTitle} onClose={vi.fn()}>
|
|
143
|
+
body
|
|
144
|
+
</MobilePopup>,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(screen.getByText(longTitle)).toHaveClass('nb-mobile-action-drawer-title');
|
|
148
|
+
expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('nb-mobile-action-drawer-close-icon');
|
|
149
|
+
});
|
|
150
|
+
});
|