@nocobase/client-v2 2.4.0-alpha.7 → 2.4.0-alpha.9
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/flow/components/filter/VariableFilterItem.d.ts +3 -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/blocks/form/QuickEditFormModel.d.ts +1 -0
- package/es/index.mjs +63 -63
- package/lib/index.js +63 -63
- package/package.json +7 -7
- 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/flow/components/filter/VariableFilterItem.tsx +3 -1
- 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/blocks/form/QuickEditFormModel.tsx +45 -11
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
- 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/RecordSelectFieldModel.tsx +9 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +17 -10
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +1 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
- package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
- package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nocobase/client-v2",
|
|
3
|
-
"version": "2.4.0-alpha.
|
|
3
|
+
"version": "2.4.0-alpha.9",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"module": "es/index.mjs",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@formily/antd-v5": "1.2.3",
|
|
28
28
|
"@formily/react": "^2.2.27",
|
|
29
29
|
"@formily/shared": "^2.2.27",
|
|
30
|
-
"@nocobase/evaluators": "2.4.0-alpha.
|
|
31
|
-
"@nocobase/flow-engine": "2.4.0-alpha.
|
|
32
|
-
"@nocobase/sdk": "2.4.0-alpha.
|
|
33
|
-
"@nocobase/shared": "2.4.0-alpha.
|
|
34
|
-
"@nocobase/utils": "2.4.0-alpha.
|
|
30
|
+
"@nocobase/evaluators": "2.4.0-alpha.9",
|
|
31
|
+
"@nocobase/flow-engine": "2.4.0-alpha.9",
|
|
32
|
+
"@nocobase/sdk": "2.4.0-alpha.9",
|
|
33
|
+
"@nocobase/shared": "2.4.0-alpha.9",
|
|
34
|
+
"@nocobase/utils": "2.4.0-alpha.9",
|
|
35
35
|
"ahooks": "^3.7.2",
|
|
36
36
|
"antd": "5.24.2",
|
|
37
37
|
"antd-style": "3.7.1",
|
|
@@ -49,5 +49,5 @@
|
|
|
49
49
|
"react-router-dom": "^6.30.1",
|
|
50
50
|
"zxing-wasm": "^3.1.3"
|
|
51
51
|
},
|
|
52
|
-
"gitHead": "
|
|
52
|
+
"gitHead": "d383bdb224c62d1ba21c3dffd682165f3385a11b"
|
|
53
53
|
}
|
package/src/BaseApplication.tsx
CHANGED
|
@@ -274,6 +274,8 @@ export abstract class BaseApplication<
|
|
|
274
274
|
this.eventBus.addEventListener('auth:tokenChanged', (event: Event) => {
|
|
275
275
|
const detail = (event as CustomEvent<AuthTokenPayload>).detail;
|
|
276
276
|
if (!detail?.token) {
|
|
277
|
+
// 登录态已结束(退出登录或 token 失效),清空路由缓存,避免下一个用户沿用上一个用户的可访问路由。
|
|
278
|
+
this.context.routeRepository?.clear();
|
|
277
279
|
return;
|
|
278
280
|
}
|
|
279
281
|
this.setTokenInWebSocket(detail);
|
package/src/RouteRepository.ts
CHANGED
|
@@ -153,6 +153,20 @@ export class RouteRepository {
|
|
|
153
153
|
return loadingPromise;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* 清空所有布局的路由缓存,并丢弃清空前发起的请求结果。
|
|
158
|
+
*
|
|
159
|
+
* 用于登录态失效(如退出登录)时,避免下一个用户沿用上一个用户的可访问路由。
|
|
160
|
+
*/
|
|
161
|
+
clear() {
|
|
162
|
+
this.routeCaches.clear();
|
|
163
|
+
this.accessibleLoadingPromises.clear();
|
|
164
|
+
this.refreshRequestIds.forEach((requestId, layoutUid) => {
|
|
165
|
+
this.refreshRequestIds.set(layoutUid, requestId + 1);
|
|
166
|
+
});
|
|
167
|
+
this.syncRoutesProperty();
|
|
168
|
+
}
|
|
169
|
+
|
|
156
170
|
/**
|
|
157
171
|
* 订阅路由缓存变化,用于驱动 React 上下文刷新。
|
|
158
172
|
*
|
|
@@ -193,6 +193,57 @@ describe('RouteRepository', () => {
|
|
|
193
193
|
unsubscribeCustom();
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
it('should drop cached routes of every layout when cleared', () => {
|
|
197
|
+
const { repository } = createRouteRepository();
|
|
198
|
+
|
|
199
|
+
repository.setRoutes([route('admin-page')], 'admin-layout-model');
|
|
200
|
+
repository.setRoutes([route('custom-page')], 'custom-desktop-layout-model');
|
|
201
|
+
|
|
202
|
+
repository.clear();
|
|
203
|
+
|
|
204
|
+
expect(repository.isAccessibleLoaded()).toBe(false);
|
|
205
|
+
expect(repository.listAccessible()).toEqual([]);
|
|
206
|
+
expect(repository.routes).toEqual([]);
|
|
207
|
+
const deactivateLayout = repository.activateLayout({
|
|
208
|
+
uid: 'custom-desktop-layout-model',
|
|
209
|
+
});
|
|
210
|
+
expect(repository.isAccessibleLoaded()).toBe(false);
|
|
211
|
+
expect(repository.listAccessible()).toEqual([]);
|
|
212
|
+
deactivateLayout();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should reload accessible routes after being cleared', async () => {
|
|
216
|
+
const { repository, request } = createRouteRepository();
|
|
217
|
+
request.mockResolvedValueOnce({ data: { data: [route('previous-user-page')] } });
|
|
218
|
+
await repository.ensureAccessibleLoaded();
|
|
219
|
+
|
|
220
|
+
repository.clear();
|
|
221
|
+
request.mockResolvedValueOnce({ data: { data: [route('next-user-page')] } });
|
|
222
|
+
const routes = await repository.ensureAccessibleLoaded();
|
|
223
|
+
|
|
224
|
+
expect(request).toHaveBeenCalledTimes(2);
|
|
225
|
+
expect(routes.map((item) => item.schemaUid)).toEqual(['next-user-page']);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should ignore refresh results requested before being cleared', async () => {
|
|
229
|
+
const { repository, request } = createRouteRepository();
|
|
230
|
+
let resolveStaleRequest: (value: unknown) => void = () => {};
|
|
231
|
+
request.mockImplementationOnce(
|
|
232
|
+
() =>
|
|
233
|
+
new Promise((resolve) => {
|
|
234
|
+
resolveStaleRequest = resolve;
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const staleRefresh = repository.refreshAccessible();
|
|
239
|
+
repository.clear();
|
|
240
|
+
resolveStaleRequest({ data: { data: [route('previous-user-page')] } });
|
|
241
|
+
await staleRefresh;
|
|
242
|
+
|
|
243
|
+
expect(repository.isAccessibleLoaded()).toBe(false);
|
|
244
|
+
expect(repository.listAccessible()).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
196
247
|
it('should pass the default admin layout when creating a route', async () => {
|
|
197
248
|
const { repository, create } = createRouteRepository();
|
|
198
249
|
|
|
@@ -171,6 +171,19 @@ describe('app', () => {
|
|
|
171
171
|
expect(document.documentElement.lang).toBe('ja-JP');
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
it('should clear cached accessible routes when the auth token is cleared', () => {
|
|
175
|
+
const app = new Application({ router });
|
|
176
|
+
const routeRepository = app.flowEngine.context.routeRepository;
|
|
177
|
+
routeRepository.setRoutes([{ schemaUid: 'previous-user-page' }]);
|
|
178
|
+
|
|
179
|
+
app.apiClient.auth.setToken('renewed-token');
|
|
180
|
+
expect(routeRepository.listAccessible().map((route) => route.schemaUid)).toEqual(['previous-user-page']);
|
|
181
|
+
|
|
182
|
+
app.apiClient.auth.setToken(null);
|
|
183
|
+
expect(routeRepository.isAccessibleLoaded()).toBe(false);
|
|
184
|
+
expect(routeRepository.listAccessible()).toEqual([]);
|
|
185
|
+
});
|
|
186
|
+
|
|
174
187
|
it('should escape app version html placeholder content', () => {
|
|
175
188
|
expect(getAppVersionHTML('<script>alert(1)</script>&"')).toBe(
|
|
176
189
|
'<span class="nb-app-version">v<script>alert(1)</script>&"</span>',
|
|
@@ -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
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
type QuickEditViewContainerLike = {
|
|
11
|
+
update?: (newConfig: { preventClose?: boolean }) => unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type QuickEditFieldModelLike = {
|
|
15
|
+
parent?: {
|
|
16
|
+
use?: string;
|
|
17
|
+
viewContainer?: QuickEditViewContainerLike;
|
|
18
|
+
} | null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function getQuickEditViewContainer(model: QuickEditFieldModelLike | undefined) {
|
|
22
|
+
const parent = model?.parent;
|
|
23
|
+
if (parent?.use !== 'QuickEditFormModel') {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return parent.viewContainer;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Keeps the quick edit popover open while a nested dropdown (Select / Cascader) is open. The outside click that
|
|
31
|
+
* closes the dropdown would otherwise also close the popover and drop the unsaved edit. Releasing is deferred so the
|
|
32
|
+
* closing click itself never reaches the popover as a close request.
|
|
33
|
+
*/
|
|
34
|
+
export function syncQuickEditPreventClose(model: QuickEditFieldModelLike | undefined, dropdownOpen: boolean) {
|
|
35
|
+
const container = getQuickEditViewContainer(model);
|
|
36
|
+
if (!container?.update) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (dropdownOpen) {
|
|
40
|
+
container.update({ preventClose: true });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
container.update?.({ preventClose: false });
|
|
45
|
+
}, 0);
|
|
46
|
+
}
|