@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.
- package/es/RouteRepository.d.ts +6 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/admin-shell/admin-layout/MobileTopbarActionsContext.d.ts +10 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
- package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
- package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
- package/es/flow/models/actions/LinkActionUtils.d.ts +1 -0
- package/es/flow/models/blocks/assign-form/assignFormValue.d.ts +29 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
- package/es/index.mjs +100 -100
- package/lib/index.js +102 -102
- package/package.json +9 -8
- package/src/BaseApplication.tsx +2 -0
- package/src/RouteRepository.ts +14 -0
- package/src/__tests__/RouteRepository.test.ts +51 -0
- package/src/__tests__/app.test.tsx +13 -0
- package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
- package/src/collection-manager/field-configure.ts +2 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/actions/__tests__/validation.test.ts +48 -0
- package/src/flow/admin-shell/admin-layout/MobileTopbarActionsContext.ts +13 -0
- package/src/flow/admin-shell/admin-layout/TopbarActionsBar.tsx +13 -2
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.mobile.test.tsx +205 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +42 -23
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
- package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
- package/src/flow/internal/components/Markdown/DisplayMarkdown.tsx +14 -9
- package/src/flow/internal/components/Markdown/__tests__/DisplayMarkdown.test.tsx +46 -0
- package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
- package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
- package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
- package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
- package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
- package/src/flow/models/actions/LinkActionModel.tsx +11 -9
- package/src/flow/models/actions/LinkActionUtils.ts +4 -2
- package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
- package/src/flow/models/actions/__tests__/LinkActionModel.test.ts +14 -0
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +40 -12
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.multipleSelect.test.tsx +216 -0
- package/src/flow/models/blocks/assign-form/__tests__/assignFormValue.test.ts +41 -0
- package/src/flow/models/blocks/assign-form/assignFormValue.ts +64 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
- package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
- package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +11 -2
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordPickerFieldModel.tsx +6 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +13 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +26 -2
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/associationSelect.search.test.ts +113 -0
- package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
- package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
- package/src/flow/models/fields/DisplayHtmlFieldModel.tsx +2 -1
- package/src/flow/models/fields/InputFieldModel.tsx +1 -1
- package/src/flow/models/fields/RichTextFieldModel/__tests__/RichTextFieldModel.test.tsx +74 -0
- package/src/flow/models/fields/RichTextFieldModel/index.tsx +27 -6
- package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
- package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +16 -0
- package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
- package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +1 -0
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +20 -0
- package/src/flow/models/topbar/TopbarActionModel.tsx +5 -5
- package/src/flow/models/topbar/UserCenterTopbarActionModel.tsx +8 -4
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
12
|
-
import { MemoryRouter } from 'react-router-dom';
|
|
12
|
+
import { MemoryRouter, useLocation } from 'react-router-dom';
|
|
13
13
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
14
14
|
|
|
15
15
|
const { allowMock, appMock, flowModelRendererSpy } = vi.hoisted(() => {
|
|
@@ -270,28 +270,47 @@ describe('TopbarActionsBar helpers', () => {
|
|
|
270
270
|
expect(link).not.toHaveAttribute('target', '_blank');
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
-
it('
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
273
|
+
it.each(['/nocobase/v', '/v', '/nocobase/v/apps/jhb20'])(
|
|
274
|
+
'should navigate sub-app settings without document navigation with basename %s',
|
|
275
|
+
(basename) => {
|
|
276
|
+
appMock.current.router.getBasename = () => basename;
|
|
277
|
+
const items = getTopbarPluginSettingsItems({
|
|
278
|
+
canManagePlugins: false,
|
|
279
|
+
t: (key) => key,
|
|
280
|
+
settings: [
|
|
281
|
+
{
|
|
282
|
+
key: 'ai',
|
|
283
|
+
name: 'ai',
|
|
284
|
+
title: 'AI employees',
|
|
285
|
+
path: '/admin/settings/ai',
|
|
286
|
+
icon: null,
|
|
287
|
+
componentLoader: async () => null,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
});
|
|
291
|
+
const item = items[0];
|
|
292
|
+
if (!item || !('label' in item)) {
|
|
293
|
+
throw new Error('Expected settings menu item');
|
|
294
|
+
}
|
|
295
|
+
const appBase = basename.endsWith('/apps/jhb20') ? basename : `${basename}/apps/jhb20`;
|
|
296
|
+
const targetHref = `${appBase}/admin/settings/ai`;
|
|
297
|
+
const LocationDisplay = () => <output aria-label="Current route">{useLocation().pathname}</output>;
|
|
298
|
+
render(
|
|
299
|
+
<MemoryRouter basename={basename} initialEntries={[`${appBase}/admin/a3pq1t1773a`]}>
|
|
300
|
+
{item.label}
|
|
301
|
+
<LocationDisplay />
|
|
302
|
+
</MemoryRouter>,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const link = screen.getByRole('link', { name: 'AI employees' });
|
|
306
|
+
expect(link).toHaveAttribute('href', targetHref);
|
|
307
|
+
expect(link).not.toHaveAttribute('target', '_blank');
|
|
308
|
+
const click = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
|
|
309
|
+
fireEvent(link, click);
|
|
310
|
+
expect(click.defaultPrevented).toBe(true);
|
|
311
|
+
expect(screen.getByLabelText('Current route')).toHaveTextContent(targetHref.slice(basename.length));
|
|
312
|
+
},
|
|
313
|
+
);
|
|
295
314
|
|
|
296
315
|
it('should not treat admin-like paths as admin runtime', () => {
|
|
297
316
|
const items = getTopbarPluginSettingsItems({
|
|
@@ -71,6 +71,8 @@ interface Props {
|
|
|
71
71
|
enableDateVariableAsConstant?: boolean;
|
|
72
72
|
/** 是否允许在变量选择器中使用 RunJS。默认 true,保持历史行为。 */
|
|
73
73
|
allowRunJS?: boolean;
|
|
74
|
+
/** 是否允许在变量选择器中使用内置日期变量。默认 true。 */
|
|
75
|
+
allowDateVariables?: boolean;
|
|
74
76
|
maxAssociationFieldDepth?: number;
|
|
75
77
|
disabled?: boolean;
|
|
76
78
|
variableConverters?: VariableInputProps['converters'];
|
|
@@ -444,6 +446,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
444
446
|
preferFormItemFieldModel,
|
|
445
447
|
associationFieldNamesOverride,
|
|
446
448
|
allowRunJS = true,
|
|
449
|
+
allowDateVariables = true,
|
|
447
450
|
maxAssociationFieldDepth = 2,
|
|
448
451
|
disabled = false,
|
|
449
452
|
variableConverters,
|
|
@@ -972,6 +975,7 @@ export const FieldAssignValueInput: React.FC<Props> = ({
|
|
|
972
975
|
style={{ width: '100%' }}
|
|
973
976
|
clearValue={''}
|
|
974
977
|
allowRunJS={allowRunJS}
|
|
978
|
+
allowDateVariables={allowDateVariables}
|
|
975
979
|
disabled={disabled}
|
|
976
980
|
converters={variableConverters}
|
|
977
981
|
/>
|
|
@@ -71,6 +71,7 @@ export type FieldValueVariableInputProps = Omit<
|
|
|
71
71
|
isDateLikeField: boolean;
|
|
72
72
|
dateComponentProps: DateVariableComponentProps;
|
|
73
73
|
allowRunJS?: boolean;
|
|
74
|
+
allowDateVariables?: boolean;
|
|
74
75
|
converters?: VariableInputProps['converters'];
|
|
75
76
|
};
|
|
76
77
|
|
|
@@ -149,6 +150,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
149
150
|
isDateLikeField,
|
|
150
151
|
dateComponentProps,
|
|
151
152
|
allowRunJS = true,
|
|
153
|
+
allowDateVariables = true,
|
|
152
154
|
converters,
|
|
153
155
|
clearValue = '',
|
|
154
156
|
disabled = false,
|
|
@@ -168,7 +170,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
168
170
|
return Component;
|
|
169
171
|
}, [dateComponentProps, isDateLikeField]);
|
|
170
172
|
|
|
171
|
-
const parsedDateConfig = parseCtxDateExpressionConfig(value);
|
|
173
|
+
const parsedDateConfig = allowDateVariables ? parseCtxDateExpressionConfig(value) : undefined;
|
|
172
174
|
const restoreLegacyNowForPureDate =
|
|
173
175
|
dateComponentProps.exactNormalizeMode === 'date' &&
|
|
174
176
|
parsedDateConfig?.kind === 'preset' &&
|
|
@@ -230,14 +232,18 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
230
232
|
paths: ['null'],
|
|
231
233
|
render: (props) => <NullComponent {...props} />,
|
|
232
234
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
235
|
+
...(allowDateVariables
|
|
236
|
+
? [
|
|
237
|
+
{
|
|
238
|
+
title: tExpr('Date'),
|
|
239
|
+
name: 'date',
|
|
240
|
+
type: 'date',
|
|
241
|
+
paths: ['date'],
|
|
242
|
+
selectable: false,
|
|
243
|
+
children: dateChildren,
|
|
244
|
+
} satisfies MetaTreeNode,
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
241
247
|
...(allowRunJS
|
|
242
248
|
? [
|
|
243
249
|
{
|
|
@@ -257,6 +263,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
257
263
|
DateEditor,
|
|
258
264
|
NullComponent,
|
|
259
265
|
RunJSComponent,
|
|
266
|
+
allowDateVariables,
|
|
260
267
|
allowRunJS,
|
|
261
268
|
baseMetaTree,
|
|
262
269
|
dateComponentProps.exactNormalizeMode,
|
|
@@ -294,7 +301,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
294
301
|
const firstPath = meta?.paths?.[0];
|
|
295
302
|
if (firstPath === 'constant') return ConstantComponent;
|
|
296
303
|
if (firstPath === 'null') return NullComponent;
|
|
297
|
-
if (firstPath === 'date') return DateEditor;
|
|
304
|
+
if (allowDateVariables && firstPath === 'date') return DateEditor;
|
|
298
305
|
if (allowRunJS && firstPath === 'runjs') return RunJSComponent;
|
|
299
306
|
return null;
|
|
300
307
|
},
|
|
@@ -304,7 +311,7 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
304
311
|
const firstPath = item?.paths?.[0];
|
|
305
312
|
if (firstPath === 'constant') return '';
|
|
306
313
|
if (firstPath === 'null') return null;
|
|
307
|
-
if (firstPath === 'date') {
|
|
314
|
+
if (allowDateVariables && firstPath === 'date') {
|
|
308
315
|
return createInitialDateConfig(item.paths[1], isDateLikeField, dateComponentProps);
|
|
309
316
|
}
|
|
310
317
|
if (allowRunJS && firstPath === 'runjs') return { code: '', version: 'v2' };
|
|
@@ -315,7 +322,9 @@ export const FieldValueVariableInput: React.FC<FieldValueVariableInputProps> = (
|
|
|
315
322
|
if (external !== undefined) return external;
|
|
316
323
|
if (currentValue === null) return ['null'];
|
|
317
324
|
if (allowRunJS && isRunJSValue(currentValue)) return ['runjs'];
|
|
318
|
-
if (isDateVariableEditConfig(currentValue))
|
|
325
|
+
if (allowDateVariables && isDateVariableEditConfig(currentValue)) {
|
|
326
|
+
return ['date', getDateNodeName(currentValue)];
|
|
327
|
+
}
|
|
319
328
|
return typeof currentValue === 'string' && isVariableExpression(currentValue)
|
|
320
329
|
? parseValueToPath(currentValue)
|
|
321
330
|
: ['constant'];
|
|
@@ -50,6 +50,7 @@ function renderInput(options?: {
|
|
|
50
50
|
value?: unknown;
|
|
51
51
|
isDateLikeField?: boolean;
|
|
52
52
|
dateComponentProps?: DateVariableComponentProps;
|
|
53
|
+
allowDateVariables?: boolean;
|
|
53
54
|
}) {
|
|
54
55
|
const onChange = vi.fn();
|
|
55
56
|
render(
|
|
@@ -62,6 +63,7 @@ function renderInput(options?: {
|
|
|
62
63
|
runJSComponent={RunJSComponent}
|
|
63
64
|
isDateLikeField={options?.isDateLikeField ?? false}
|
|
64
65
|
dateComponentProps={options?.dateComponentProps ?? DEFAULT_DATE_VARIABLE_COMPONENT_PROPS}
|
|
66
|
+
allowDateVariables={options?.allowDateVariables}
|
|
65
67
|
/>,
|
|
66
68
|
);
|
|
67
69
|
return onChange;
|
|
@@ -110,6 +112,13 @@ describe('FieldValueVariableInput', () => {
|
|
|
110
112
|
expect(tree[4].name).toBe('currentUser');
|
|
111
113
|
});
|
|
112
114
|
|
|
115
|
+
it('omits the built-in Date variables when they are disabled', async () => {
|
|
116
|
+
renderInput({ isDateLikeField: true, allowDateVariables: false });
|
|
117
|
+
|
|
118
|
+
const tree = await resolveMetaTree();
|
|
119
|
+
expect(tree.map((node) => node.name)).toEqual(['constant', 'null', 'runjs', 'currentUser']);
|
|
120
|
+
});
|
|
121
|
+
|
|
113
122
|
it('does not allow Now for pure date fields', async () => {
|
|
114
123
|
const dateComponentProps: DateVariableComponentProps = {
|
|
115
124
|
...DEFAULT_DATE_VARIABLE_COMPONENT_PROPS,
|
|
@@ -130,7 +130,9 @@ export interface VariableFilterItemProps {
|
|
|
130
130
|
rightAsVariable?: boolean;
|
|
131
131
|
/**
|
|
132
132
|
* 右侧 VariableInput 的 metaTree 提供器;
|
|
133
|
-
*
|
|
133
|
+
* 默认使用视图上下文的 metaTree:useFlowViewContext().getPropertyMetaTree()。
|
|
134
|
+
* 注意它取自视图上下文而非 `model.context`:当调用方传入的 model 与打开设置弹窗的模型不是同一个时,
|
|
135
|
+
* 右侧变量不会跟随 model 变化,需要显式传入本属性。
|
|
134
136
|
*/
|
|
135
137
|
rightMetaTree?: MetaTreeNode[] | (() => MetaTreeNode[] | Promise<MetaTreeNode[]>);
|
|
136
138
|
/**
|
|
@@ -139,9 +141,16 @@ export interface VariableFilterItemProps {
|
|
|
139
141
|
rightVariableConverters?: Pick<Converters, 'resolvePathFromValue' | 'resolveValueFromPath'>;
|
|
140
142
|
ignoreFieldNames?: string[];
|
|
141
143
|
maxAssociationFieldDepth?: number;
|
|
144
|
+
/**
|
|
145
|
+
* 右侧变量树的关联层级上限,默认与 `maxAssociationFieldDepth` 一致。
|
|
146
|
+
* 传 `null` 表示不限制:右侧是变量树而非集合字段树,其深度可能已由调用方约束
|
|
147
|
+
* (例如工作流的变量树由触发器的「预加载关系数据」决定),此时再按左侧的层级上限裁剪
|
|
148
|
+
* 会让已配置好的深层变量选不到。
|
|
149
|
+
*/
|
|
150
|
+
rightMaxAssociationFieldDepth?: number | null;
|
|
142
151
|
}
|
|
143
152
|
|
|
144
|
-
function limitMetaTreeIfNeeded(nodes: MetaTreeNode[], maxAssociationFieldDepth?: number) {
|
|
153
|
+
function limitMetaTreeIfNeeded(nodes: MetaTreeNode[], maxAssociationFieldDepth?: number | null) {
|
|
145
154
|
if (typeof maxAssociationFieldDepth !== 'number') {
|
|
146
155
|
return nodes;
|
|
147
156
|
}
|
|
@@ -367,6 +376,7 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
|
|
367
376
|
rightVariableConverters,
|
|
368
377
|
ignoreFieldNames,
|
|
369
378
|
maxAssociationFieldDepth,
|
|
379
|
+
rightMaxAssociationFieldDepth = maxAssociationFieldDepth,
|
|
370
380
|
}) => {
|
|
371
381
|
// 使用 View 上下文,确保可访问 ctx.view 的异步子树
|
|
372
382
|
const ctx = useFlowViewContext();
|
|
@@ -685,10 +695,10 @@ export const VariableFilterItem: React.FC<VariableFilterItemProps> = observer(
|
|
|
685
695
|
{ title: t('Null'), name: 'null', type: 'object', paths: ['null'], render: NullComponent },
|
|
686
696
|
...nodes,
|
|
687
697
|
],
|
|
688
|
-
|
|
698
|
+
rightMaxAssociationFieldDepth,
|
|
689
699
|
);
|
|
690
700
|
};
|
|
691
|
-
}, [rightMetaTree, ctx, staticInputRenderer, NullComponent, t,
|
|
701
|
+
}, [rightMetaTree, ctx, staticInputRenderer, NullComponent, t, rightMaxAssociationFieldDepth]);
|
|
692
702
|
|
|
693
703
|
// 当启用右侧变量输入时,构造 VariableInput 的 converters:
|
|
694
704
|
// - 变量模式:返回 null 让 VariableInput 渲染 VariableTag
|
|
@@ -0,0 +1,158 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
13
|
+
import type { MetaTreeNode } from '@nocobase/flow-engine';
|
|
14
|
+
import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
|
|
15
|
+
import { VariableFilterItem } from '../VariableFilterItem';
|
|
16
|
+
import { createMockFlowApp, TestCollectionFieldInterface } from '../../../__tests__/helpers/mockFlowApp';
|
|
17
|
+
|
|
18
|
+
const captured = vi.hoisted(() => ({ metaTrees: [] as any[] }));
|
|
19
|
+
|
|
20
|
+
vi.mock('@nocobase/flow-engine', async () => {
|
|
21
|
+
const actual = await vi.importActual<any>('@nocobase/flow-engine');
|
|
22
|
+
const MockVariableInput = ({ onChange, metaTree }: any) => {
|
|
23
|
+
if (metaTree) {
|
|
24
|
+
captured.metaTrees.push(metaTree);
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
data-testid="variable-input"
|
|
30
|
+
onClick={() =>
|
|
31
|
+
onChange?.('title', {
|
|
32
|
+
interface: 'input',
|
|
33
|
+
uiSchema: { 'x-component': 'Input' },
|
|
34
|
+
paths: ['collection', 'title'],
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
>
|
|
38
|
+
mock-variable-input
|
|
39
|
+
</button>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
return { ...actual, VariableInput: MockVariableInput };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// $context.data (not an association) -> category (m2o) -> book (m2o) -> createdBy (not counted) -> departments (m2m).
|
|
46
|
+
// Mirrors a workflow collection-event trigger that preloads `category.book.createdBy.departments`.
|
|
47
|
+
function buildRightMetaTree(): MetaTreeNode[] {
|
|
48
|
+
const departments: MetaTreeNode = {
|
|
49
|
+
name: 'departments',
|
|
50
|
+
title: 'Departments',
|
|
51
|
+
type: 'object',
|
|
52
|
+
interface: 'm2m',
|
|
53
|
+
paths: ['$context', 'data', 'category', 'book', 'createdBy', 'departments'],
|
|
54
|
+
};
|
|
55
|
+
const createdBy: MetaTreeNode = {
|
|
56
|
+
name: 'createdBy',
|
|
57
|
+
title: 'Created by',
|
|
58
|
+
type: 'object',
|
|
59
|
+
interface: 'createdBy',
|
|
60
|
+
paths: ['$context', 'data', 'category', 'book', 'createdBy'],
|
|
61
|
+
children: async () => [departments],
|
|
62
|
+
};
|
|
63
|
+
const book: MetaTreeNode = {
|
|
64
|
+
name: 'book',
|
|
65
|
+
title: 'Book',
|
|
66
|
+
type: 'object',
|
|
67
|
+
interface: 'obo',
|
|
68
|
+
paths: ['$context', 'data', 'category', 'book'],
|
|
69
|
+
children: async () => [createdBy],
|
|
70
|
+
};
|
|
71
|
+
const category: MetaTreeNode = {
|
|
72
|
+
name: 'category',
|
|
73
|
+
title: 'Category',
|
|
74
|
+
type: 'object',
|
|
75
|
+
interface: 'm2o',
|
|
76
|
+
paths: ['$context', 'data', 'category'],
|
|
77
|
+
children: async () => [book],
|
|
78
|
+
};
|
|
79
|
+
const data: MetaTreeNode = {
|
|
80
|
+
name: 'data',
|
|
81
|
+
title: 'Trigger data',
|
|
82
|
+
type: 'object',
|
|
83
|
+
paths: ['$context', 'data'],
|
|
84
|
+
children: async () => [category],
|
|
85
|
+
};
|
|
86
|
+
return [{ name: '$context', title: 'Trigger variables', type: '', paths: ['$context'], children: [data] }];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveChildren(node: MetaTreeNode | undefined) {
|
|
90
|
+
const children = node?.children;
|
|
91
|
+
if (typeof children === 'function') {
|
|
92
|
+
return (await (children as () => Promise<MetaTreeNode[]>)()) ?? [];
|
|
93
|
+
}
|
|
94
|
+
return Array.isArray(children) ? children : [];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function walkToCreatedByChildren(metaTree: any) {
|
|
98
|
+
const roots: MetaTreeNode[] = typeof metaTree === 'function' ? await metaTree() : metaTree;
|
|
99
|
+
const context = roots.find((node) => node.name === '$context');
|
|
100
|
+
const data = (await resolveChildren(context)).find((node) => node.name === 'data');
|
|
101
|
+
const category = (await resolveChildren(data)).find((node) => node.name === 'category');
|
|
102
|
+
const book = (await resolveChildren(category)).find((node) => node.name === 'book');
|
|
103
|
+
const createdBy = (await resolveChildren(book)).find((node) => node.name === 'createdBy');
|
|
104
|
+
return (await resolveChildren(createdBy)).map((node) => node.name);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createModel() {
|
|
108
|
+
const engine = new FlowEngine();
|
|
109
|
+
const model = new FlowModel({ uid: 'm-variable-filter-right', flowEngine: engine });
|
|
110
|
+
const app = createMockFlowApp();
|
|
111
|
+
model.context.defineProperty('app', { value: app });
|
|
112
|
+
|
|
113
|
+
class InputInterface extends TestCollectionFieldInterface {
|
|
114
|
+
name = 'input';
|
|
115
|
+
group = 'basic';
|
|
116
|
+
filterable = { operators: [{ value: '$eq', label: 'Equals' }] };
|
|
117
|
+
}
|
|
118
|
+
app.addFieldInterfaces([InputInterface]);
|
|
119
|
+
|
|
120
|
+
const ds = engine.dataSourceManager.getDataSource('main');
|
|
121
|
+
ds.addCollection({
|
|
122
|
+
name: 'posts',
|
|
123
|
+
fields: [{ name: 'title', type: 'string', interface: 'input', uiSchema: { 'x-component': 'Input' } }],
|
|
124
|
+
});
|
|
125
|
+
model.context.defineProperty('collection', { get: () => ds.getCollection('posts') });
|
|
126
|
+
|
|
127
|
+
return model as any;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderItem(props: Record<string, unknown>) {
|
|
131
|
+
const value = { path: 'title', operator: '$eq', value: '' } as any;
|
|
132
|
+
const model = createModel();
|
|
133
|
+
render(
|
|
134
|
+
<VariableFilterItem value={value} model={model} rightAsVariable rightMetaTree={buildRightMetaTree()} {...props} />,
|
|
135
|
+
);
|
|
136
|
+
fireEvent.click(screen.getAllByTestId('variable-input')[0]);
|
|
137
|
+
return captured.metaTrees.at(-1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe('VariableFilterItem right-side association depth', () => {
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
document.body.innerHTML = '';
|
|
143
|
+
captured.metaTrees = [];
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('applies maxAssociationFieldDepth to the right tree by default', async () => {
|
|
147
|
+
const metaTree = renderItem({ maxAssociationFieldDepth: 2 });
|
|
148
|
+
// `departments` is the third association down the path, so the default cap removes it.
|
|
149
|
+
expect(await walkToCreatedByChildren(metaTree)).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// The right side is a variable tree whose depth is decided by its provider (in workflow, by the trigger's
|
|
153
|
+
// "Preload associations" config). Capping it by the left-side field-picker limit hid preloaded variables.
|
|
154
|
+
it('leaves the right tree untouched when rightMaxAssociationFieldDepth is null', async () => {
|
|
155
|
+
const metaTree = renderItem({ maxAssociationFieldDepth: 2, rightMaxAssociationFieldDepth: null });
|
|
156
|
+
expect(await walkToCreatedByChildren(metaTree)).toEqual(['departments']);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -7,16 +7,17 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import React, { useRef, useState
|
|
11
|
-
import { useParseMarkdown, convertToText } from './util';
|
|
12
|
-
import { useMarkdownStyles } from './style';
|
|
10
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
13
11
|
import { Tooltip } from 'antd';
|
|
12
|
+
import { useMarkdownStyles } from './style';
|
|
13
|
+
import { convertToText, useParseMarkdown } from './util';
|
|
14
14
|
|
|
15
15
|
export const DisplayMarkdown = (props) => {
|
|
16
|
-
const { textOnly, overflowMode, style } = props;
|
|
16
|
+
const { textOnly, overflowMode, sanitizeHtml, style } = props;
|
|
17
17
|
const markdownClass = useMarkdownStyles();
|
|
18
18
|
const { html = '' } = useParseMarkdown(props.value);
|
|
19
|
-
const
|
|
19
|
+
const displayHtml = sanitizeHtml ? sanitizeHtml(html) : html;
|
|
20
|
+
const text = convertToText(displayHtml);
|
|
20
21
|
const isEllipsis = overflowMode === 'ellipsis';
|
|
21
22
|
|
|
22
23
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
@@ -29,7 +30,7 @@ export const DisplayMarkdown = (props) => {
|
|
|
29
30
|
const overflowed = el.scrollWidth > el.clientWidth || el.scrollHeight > el.clientHeight;
|
|
30
31
|
setIsOverflowed(overflowed);
|
|
31
32
|
}
|
|
32
|
-
}, [
|
|
33
|
+
}, [displayHtml, style, overflowMode]);
|
|
33
34
|
|
|
34
35
|
// 通用样式(用于 ellipsis 模式)
|
|
35
36
|
const baseStyle: React.CSSProperties = {
|
|
@@ -48,11 +49,15 @@ export const DisplayMarkdown = (props) => {
|
|
|
48
49
|
: {}),
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
const content = (
|
|
52
|
+
const content = textOnly ? (
|
|
53
|
+
<div ref={contentRef} className={`${markdownClass} nb-markdown`} style={baseStyle}>
|
|
54
|
+
{text}
|
|
55
|
+
</div>
|
|
56
|
+
) : (
|
|
52
57
|
<div
|
|
53
58
|
ref={contentRef}
|
|
54
59
|
className={`${markdownClass} nb-markdown`}
|
|
55
|
-
dangerouslySetInnerHTML={{ __html:
|
|
60
|
+
dangerouslySetInnerHTML={{ __html: displayHtml }}
|
|
56
61
|
style={baseStyle}
|
|
57
62
|
/>
|
|
58
63
|
);
|
|
@@ -61,7 +66,7 @@ export const DisplayMarkdown = (props) => {
|
|
|
61
66
|
<Tooltip
|
|
62
67
|
title={
|
|
63
68
|
<div
|
|
64
|
-
dangerouslySetInnerHTML={{ __html:
|
|
69
|
+
dangerouslySetInnerHTML={{ __html: displayHtml }}
|
|
65
70
|
style={{
|
|
66
71
|
maxHeight: 500,
|
|
67
72
|
overflowY: 'auto',
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { render } from '@nocobase/test/client';
|
|
11
|
+
import { sanitizeRichTextHtml } from '@nocobase/utils/client';
|
|
12
|
+
import React from 'react';
|
|
13
|
+
import { DisplayMarkdown } from '../DisplayMarkdown';
|
|
14
|
+
|
|
15
|
+
vi.mock('../util', () => ({
|
|
16
|
+
useParseMarkdown: (value: string) => ({ html: value }),
|
|
17
|
+
convertToText: (value: string) => {
|
|
18
|
+
const element = document.createElement('div');
|
|
19
|
+
element.innerHTML = value;
|
|
20
|
+
return element.textContent || '';
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe('DisplayMarkdown', () => {
|
|
25
|
+
it('sanitizes HTML mode', () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<DisplayMarkdown
|
|
28
|
+
value={'<p>safe</p><img src="https://example.com/image.png" onerror="alert(1)"><script>alert(1)</script>'}
|
|
29
|
+
sanitizeHtml={sanitizeRichTextHtml}
|
|
30
|
+
textOnly={false}
|
|
31
|
+
/>,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(container.querySelector('p')).toHaveTextContent('safe');
|
|
35
|
+
expect(container.querySelector('img')).not.toHaveAttribute('onerror');
|
|
36
|
+
expect(container.querySelector('script')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders decoded text as a text node', () => {
|
|
40
|
+
const payload = '<img src=x onerror=alert(document.domain)>';
|
|
41
|
+
const { container } = render(<DisplayMarkdown value={payload} sanitizeHtml={sanitizeRichTextHtml} textOnly />);
|
|
42
|
+
|
|
43
|
+
expect(container).toHaveTextContent('<img src=x onerror=alert(document.domain)>');
|
|
44
|
+
expect(container.querySelector('img')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|