@nocobase/client-v2 2.3.0-beta.2 → 2.3.0-beta.20
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/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 +79 -79
- package/lib/index.js +84 -84
- 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/__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 +7 -1
- 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 +9 -0
- 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/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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
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 { beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
+
// Prime the model module graph: the association field models sit in an import cycle that only resolves cleanly when the
|
|
12
|
+
// models barrel is loaded first.
|
|
13
|
+
import '../../../models';
|
|
14
|
+
import { FlowEngine, FlowModel } from '@nocobase/flow-engine';
|
|
15
|
+
import { CascadeSelectFieldModel } from '../../../models/fields/AssociationFieldModel/CascadeSelectFieldModel';
|
|
16
|
+
import { RecordSelectFieldModel } from '../../../models/fields/AssociationFieldModel/RecordSelectFieldModel';
|
|
17
|
+
import { SelectFieldModel } from '../../../models/fields/SelectFieldModel';
|
|
18
|
+
import {
|
|
19
|
+
createQuickEditDataScopeContext,
|
|
20
|
+
findDataScopeStep,
|
|
21
|
+
getQuickEditDataScopeFilter,
|
|
22
|
+
isEmptyDataScopeFilter,
|
|
23
|
+
} from '../quickEditDataScope';
|
|
24
|
+
|
|
25
|
+
const resolveSubTree = async (subTree: unknown) => {
|
|
26
|
+
if (typeof subTree === 'function') {
|
|
27
|
+
return (await (subTree as () => Promise<Array<{ name: string }>>)()) || [];
|
|
28
|
+
}
|
|
29
|
+
return (subTree as Array<{ name: string }>) || [];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('quickEditDataScope', () => {
|
|
33
|
+
describe('findDataScopeStep', () => {
|
|
34
|
+
it('finds the data scope step of the dropdown association editor', () => {
|
|
35
|
+
expect(findDataScopeStep(RecordSelectFieldModel)).toEqual({ flowKey: 'selectSettings', stepKey: 'dataScope' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('finds the data scope step of the cascade association editor', () => {
|
|
39
|
+
expect(findDataScopeStep(CascadeSelectFieldModel)).toEqual({ flowKey: 'selectSettings', stepKey: 'dataScope' });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns null for editors without a data scope step', () => {
|
|
43
|
+
expect(findDataScopeStep(SelectFieldModel)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns null for a missing model class', () => {
|
|
47
|
+
expect(findDataScopeStep(undefined)).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('isEmptyDataScopeFilter', () => {
|
|
52
|
+
it('treats a filter without conditions as empty', () => {
|
|
53
|
+
expect(isEmptyDataScopeFilter(undefined)).toBe(true);
|
|
54
|
+
expect(isEmptyDataScopeFilter({ logic: '$and', items: [] })).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('treats a filter with conditions as non-empty', () => {
|
|
58
|
+
expect(isEmptyDataScopeFilter({ logic: '$and', items: [{ path: 'name', operator: 'eq', value: 'a' }] })).toBe(
|
|
59
|
+
false,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('getQuickEditDataScopeFilter', () => {
|
|
65
|
+
let engine: FlowEngine;
|
|
66
|
+
let column: FlowModel;
|
|
67
|
+
let displayField: FlowModel;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
engine = new FlowEngine();
|
|
71
|
+
column = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'table-column' });
|
|
72
|
+
displayField = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'display-field', parentId: column.uid });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reads the filter configured on the owning table column', () => {
|
|
76
|
+
const filter = { logic: '$and', items: [{ path: 'status', operator: 'eq', value: 'active' }] };
|
|
77
|
+
column.setStepParams('tableColumnSettings', 'quickEditDataScope', { filter });
|
|
78
|
+
|
|
79
|
+
expect(getQuickEditDataScopeFilter(displayField)).toEqual(filter);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ignores an unconfigured filter so no step params are injected', () => {
|
|
83
|
+
column.setStepParams('tableColumnSettings', 'quickEditDataScope', { filter: { logic: '$and', items: [] } });
|
|
84
|
+
|
|
85
|
+
expect(getQuickEditDataScopeFilter(displayField)).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns undefined when there is no source field model', () => {
|
|
89
|
+
expect(getQuickEditDataScopeFilter(undefined)).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('createQuickEditDataScopeContext', () => {
|
|
94
|
+
let engine: FlowEngine;
|
|
95
|
+
let column: FlowModel;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
engine = new FlowEngine();
|
|
99
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
100
|
+
ds.addCollection({
|
|
101
|
+
name: 'departments',
|
|
102
|
+
filterTargetKey: 'id',
|
|
103
|
+
titleField: 'name',
|
|
104
|
+
fields: [
|
|
105
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
106
|
+
{ name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
ds.addCollection({
|
|
110
|
+
name: 'members',
|
|
111
|
+
filterTargetKey: 'id',
|
|
112
|
+
fields: [
|
|
113
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
114
|
+
{ name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
|
|
115
|
+
{
|
|
116
|
+
name: 'department',
|
|
117
|
+
type: 'belongsTo',
|
|
118
|
+
interface: 'm2o',
|
|
119
|
+
target: 'departments',
|
|
120
|
+
targetKey: 'id',
|
|
121
|
+
uiSchema: { title: 'Department' },
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
const membersCollection = engine.context.dataSourceManager.getCollection('main', 'members');
|
|
126
|
+
column = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'scoped-column' });
|
|
127
|
+
column.context.defineProperty('collection', { get: () => membersCollection });
|
|
128
|
+
column.context.defineProperty('collectionField', { get: () => membersCollection.getField('department') });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('points the left-hand field list at the association target', async () => {
|
|
132
|
+
const scoped = await createQuickEditDataScopeContext(column);
|
|
133
|
+
|
|
134
|
+
expect(scoped.collection?.name).toBe('departments');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('offers the edited row as a right-hand variable that the column context alone does not provide', async () => {
|
|
138
|
+
// Regression guard: the right-hand tree used to come from the settings view context, which delegates to the
|
|
139
|
+
// column and therefore carries no row record, so "Current record" was missing from the variable picker.
|
|
140
|
+
const columnTree = column.context.getPropertyMetaTree();
|
|
141
|
+
expect(columnTree.find((node) => node.name === 'record')).toBeUndefined();
|
|
142
|
+
|
|
143
|
+
const scoped = await createQuickEditDataScopeContext(column);
|
|
144
|
+
const scopedTree = scoped.getPropertyMetaTree();
|
|
145
|
+
|
|
146
|
+
expect(scopedTree.find((node) => node.name === 'record')).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('keeps the row record describing the table collection rather than the association target', async () => {
|
|
150
|
+
const scoped = await createQuickEditDataScopeContext(column);
|
|
151
|
+
const recordSubTree = await resolveSubTree(scoped.getPropertyMetaTree('{{ ctx.record }}'));
|
|
152
|
+
|
|
153
|
+
expect(recordSubTree.map((node) => node.name)).toContain('level');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { getQuickEditViewContainer, syncQuickEditPreventClose } from '../quickEditViewContainer';
|
|
12
|
+
|
|
13
|
+
describe('quickEditViewContainer', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.useFakeTimers();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.useRealTimers();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('only resolves the view container of a QuickEditFormModel parent', () => {
|
|
23
|
+
const viewContainer = { update: vi.fn() };
|
|
24
|
+
expect(getQuickEditViewContainer({ parent: { use: 'QuickEditFormModel', viewContainer } })).toBe(viewContainer);
|
|
25
|
+
expect(getQuickEditViewContainer({ parent: { use: 'EditFormModel', viewContainer } })).toBeUndefined();
|
|
26
|
+
expect(getQuickEditViewContainer({ parent: null })).toBeUndefined();
|
|
27
|
+
expect(getQuickEditViewContainer(undefined)).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('locks the popover while the dropdown is open and releases it after the closing click', () => {
|
|
31
|
+
const update = vi.fn();
|
|
32
|
+
const model = { parent: { use: 'QuickEditFormModel', viewContainer: { update } } };
|
|
33
|
+
|
|
34
|
+
syncQuickEditPreventClose(model, true);
|
|
35
|
+
expect(update).toHaveBeenCalledWith({ preventClose: true });
|
|
36
|
+
|
|
37
|
+
syncQuickEditPreventClose(model, false);
|
|
38
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
39
|
+
|
|
40
|
+
vi.runAllTimers();
|
|
41
|
+
expect(update).toHaveBeenCalledTimes(2);
|
|
42
|
+
expect(update).toHaveBeenLastCalledWith({ preventClose: false });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('is a no-op outside of quick edit', () => {
|
|
46
|
+
const update = vi.fn();
|
|
47
|
+
syncQuickEditPreventClose({ parent: { use: 'EditFormModel', viewContainer: { update } } }, true);
|
|
48
|
+
syncQuickEditPreventClose({ parent: { use: 'QuickEditFormModel' } }, true);
|
|
49
|
+
vi.runAllTimers();
|
|
50
|
+
expect(update).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
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 type { FlowEngineContext, ModelConstructor } from '@nocobase/flow-engine';
|
|
11
|
+
import {
|
|
12
|
+
CollectionField,
|
|
13
|
+
FlowModel,
|
|
14
|
+
createCollectionContextMeta,
|
|
15
|
+
createCurrentRecordMetaFactory,
|
|
16
|
+
createEphemeralContext,
|
|
17
|
+
useFlowSettingsContext,
|
|
18
|
+
} from '@nocobase/flow-engine';
|
|
19
|
+
import { Skeleton } from 'antd';
|
|
20
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
21
|
+
import { FilterGroup, VariableFilterItem } from '../../components/filter';
|
|
22
|
+
import { FormItemModel } from '../../models/blocks/form/FormItemModel';
|
|
23
|
+
|
|
24
|
+
const DATA_SCOPE_ACTION_NAME = 'dataScope';
|
|
25
|
+
|
|
26
|
+
export const QUICK_EDIT_DATA_SCOPE_FLOW_KEY = 'tableColumnSettings';
|
|
27
|
+
export const QUICK_EDIT_DATA_SCOPE_STEP_KEY = 'quickEditDataScope';
|
|
28
|
+
|
|
29
|
+
export type DataScopeStepLocation = { flowKey: string; stepKey: string };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal shape read off a model class: the static flow registry and the `use` of each step. `ModelConstructor` does not
|
|
33
|
+
* declare the registry, so the lookup reaches for it through this shape instead of widening to `any`.
|
|
34
|
+
*/
|
|
35
|
+
type FlowRegistryHolder = {
|
|
36
|
+
globalFlowRegistry?: {
|
|
37
|
+
getFlows?: () => Map<string, { steps?: Record<string, { use?: string } | undefined> } | undefined>;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type DataScopeFilter = { logic?: string; items?: unknown[] } | undefined;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Locate the flow and step that carry the shared `dataScope` action on a field model class. Both association editors
|
|
45
|
+
* currently keep it under `selectSettings`, but the lookup resolves it from the class instead of hard-coding that name,
|
|
46
|
+
* so an editor registered later is picked up without touching the quick edit path. Returns the first match.
|
|
47
|
+
*/
|
|
48
|
+
export function findDataScopeStep(
|
|
49
|
+
ModelClass: ModelConstructor | typeof FlowModel | undefined | null,
|
|
50
|
+
): DataScopeStepLocation | null {
|
|
51
|
+
const flows = (ModelClass as unknown as FlowRegistryHolder | undefined)?.globalFlowRegistry?.getFlows?.();
|
|
52
|
+
if (!flows) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
for (const [flowKey, flow] of flows) {
|
|
56
|
+
for (const [stepKey, step] of Object.entries(flow?.steps || {})) {
|
|
57
|
+
if (step?.use === DATA_SCOPE_ACTION_NAME) {
|
|
58
|
+
return { flowKey, stepKey };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve the data scope step of the editor model that the quick edit popover builds for `collectionField`. Returns null
|
|
67
|
+
* when the field is not an association or when its default editor has no data scope step, which is what the column
|
|
68
|
+
* setting uses to decide whether to offer the item at all.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveQuickEditDataScopeStep(
|
|
71
|
+
ctx: FlowEngineContext,
|
|
72
|
+
collectionField: CollectionField,
|
|
73
|
+
): DataScopeStepLocation | null {
|
|
74
|
+
if (!collectionField?.isAssociationField?.()) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
// Resolve through the same class the quick edit popover builds its editor with, so the setting is offered exactly
|
|
78
|
+
// when the editor that will actually open has a data scope step.
|
|
79
|
+
const binding = FormItemModel.getDefaultBindingByField(ctx, collectionField);
|
|
80
|
+
if (!binding?.modelName) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return findDataScopeStep(ctx.engine?.getModelClass?.(binding.modelName));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isEmptyDataScopeFilter(filter: DataScopeFilter) {
|
|
87
|
+
return !filter || !Array.isArray(filter.items) || filter.items.length === 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read the quick edit data scope filter stored on the table column that owns `sourceFieldModel`. */
|
|
91
|
+
export function getQuickEditDataScopeFilter(sourceFieldModel?: FlowModel): DataScopeFilter {
|
|
92
|
+
const columnModel = sourceFieldModel?.parent as FlowModel | undefined;
|
|
93
|
+
const params = columnModel?.getStepParams?.(QUICK_EDIT_DATA_SCOPE_FLOW_KEY, QUICK_EDIT_DATA_SCOPE_STEP_KEY);
|
|
94
|
+
const filter = params?.filter as DataScopeFilter;
|
|
95
|
+
return isEmptyDataScopeFilter(filter) ? undefined : filter;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Context the filter builder is configured against. The setting lives on the table column, but the filter describes
|
|
100
|
+
* records of the association target, so `collection` points at the target collection to drive the left-hand field list.
|
|
101
|
+
* `record` is declared with meta only so that "Current record" is offered as a right-hand variable and describes the row
|
|
102
|
+
* being edited rather than whatever record an enclosing popup happens to supply; it has no value while configuring and
|
|
103
|
+
* resolves per row once the popover opens.
|
|
104
|
+
*/
|
|
105
|
+
export function createQuickEditDataScopeContext(columnModel: FlowModel) {
|
|
106
|
+
const collectionField = columnModel.context.collectionField as CollectionField;
|
|
107
|
+
const getTargetCollection = () => collectionField?.targetCollection ?? null;
|
|
108
|
+
const getSourceCollection = () => columnModel.context.collection ?? null;
|
|
109
|
+
return createEphemeralContext(columnModel.context, {
|
|
110
|
+
defineProperties: {
|
|
111
|
+
collection: {
|
|
112
|
+
get: getTargetCollection,
|
|
113
|
+
meta: createCollectionContextMeta(getTargetCollection, 'Current collection'),
|
|
114
|
+
},
|
|
115
|
+
record: {
|
|
116
|
+
get: () => undefined,
|
|
117
|
+
meta: createCurrentRecordMetaFactory(columnModel.context, getSourceCollection),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Wrap the column model so the filter builder reads the scoped context instead of the column's own. */
|
|
124
|
+
function useQuickEditDataScopeModel(columnModel: FlowModel): FlowModel | null {
|
|
125
|
+
const [scopedModel, setScopedModel] = useState<FlowModel | null>(null);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
let cancelled = false;
|
|
129
|
+
|
|
130
|
+
const build = async () => {
|
|
131
|
+
const scopedContext = await createQuickEditDataScopeContext(columnModel);
|
|
132
|
+
if (cancelled) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
setScopedModel(
|
|
136
|
+
new Proxy(columnModel, {
|
|
137
|
+
get(target, key, receiver) {
|
|
138
|
+
if (key === 'context') {
|
|
139
|
+
return scopedContext;
|
|
140
|
+
}
|
|
141
|
+
const value = Reflect.get(target, key, receiver);
|
|
142
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
build();
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
cancelled = true;
|
|
152
|
+
};
|
|
153
|
+
}, [columnModel]);
|
|
154
|
+
|
|
155
|
+
return scopedModel;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function QuickEditDataScopeInput(props: { value?: Record<string, any> }) {
|
|
159
|
+
const flowContext = useFlowSettingsContext<FlowModel>();
|
|
160
|
+
const scopedModel = useQuickEditDataScopeModel(flowContext.model);
|
|
161
|
+
// The right-hand variable tree defaults to the settings view context, which delegates to the table column and
|
|
162
|
+
// therefore carries no row record. Feed it the scoped context so "Current record" is offered and describes the row.
|
|
163
|
+
const rightMetaTree = useCallback(() => scopedModel?.context.getPropertyMetaTree() ?? [], [scopedModel]);
|
|
164
|
+
|
|
165
|
+
if (!scopedModel) {
|
|
166
|
+
return <Skeleton.Input active size="small" />;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<FilterGroup
|
|
171
|
+
value={props.value}
|
|
172
|
+
FilterItem={(p) => (
|
|
173
|
+
<VariableFilterItem {...p} model={scopedModel} rightAsVariable rightMetaTree={rightMetaTree} />
|
|
174
|
+
)}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
}
|