@nocobase/client-v2 2.1.29 → 2.1.31
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/components/form/ScanInput/useCodeScanner.d.ts +2 -2
- package/es/flow/models/base/BlockGridModel.d.ts +3 -0
- package/es/flow/models/blocks/table/TableColumnModel.d.ts +1 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +15 -7
- package/es/index.mjs +97 -94
- package/lib/index.js +88 -85
- package/package.json +7 -7
- package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +7 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +61 -34
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +101 -0
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +114 -18
- package/src/components/form/ScanInput/useCodeScanner.ts +166 -23
- package/src/flow/__tests__/FlowRoute.test.tsx +172 -11
- package/src/flow/actions/__tests__/actionLinkageRules.forkProps.test.ts +115 -0
- package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +102 -0
- package/src/flow/actions/linkageRules.tsx +24 -12
- package/src/flow/components/FlowRoute.tsx +72 -4
- package/src/flow/models/base/BlockGridModel.tsx +26 -0
- package/src/flow/models/base/__tests__/BlockGridModel.selectSceneActivation.test.ts +124 -0
- package/src/flow/models/blocks/table/TableBlockModel.tsx +3 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +30 -1
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.quickEditRefresh.test.ts +12 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +33 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +149 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +149 -113
- package/src/flow/utils/dataScopeFormValueClear.ts +4 -3
|
@@ -225,6 +225,108 @@ describe('ensureFormValueDrivenDataScopeClear', () => {
|
|
|
225
225
|
expect(onChange).toHaveBeenCalledWith(null);
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
+
it('does not clear a popup form field when data scope depends on the external parent item', () => {
|
|
229
|
+
const emitter = new EventEmitter();
|
|
230
|
+
const formBlock = {
|
|
231
|
+
uid: 'form-1',
|
|
232
|
+
disposed: false,
|
|
233
|
+
emitter,
|
|
234
|
+
context: {
|
|
235
|
+
form: {},
|
|
236
|
+
formValues: { staff_m2o: null },
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const onChange = vi.fn();
|
|
241
|
+
const model: any = {
|
|
242
|
+
disposed: false,
|
|
243
|
+
props: {
|
|
244
|
+
value: { id: 10 },
|
|
245
|
+
onChange,
|
|
246
|
+
},
|
|
247
|
+
context: {
|
|
248
|
+
blockModel: formBlock,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const ctx: any = {
|
|
253
|
+
model,
|
|
254
|
+
flowKey: 'selectSettings',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const filter = {
|
|
258
|
+
logic: '$and',
|
|
259
|
+
items: [{ path: 'orgId', operator: '$eq', value: '{{ ctx.item.parentItem.value.org_m2o.id }}' }],
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
ensureFormValueDrivenDataScopeClear(ctx, filter);
|
|
263
|
+
|
|
264
|
+
emitter.emit('formValuesChange', {
|
|
265
|
+
changedPaths: [['staff_m2o']],
|
|
266
|
+
allValues: { staff_m2o: { id: 10 } },
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('maps popup current item dependencies to the popup form root', () => {
|
|
273
|
+
const emitter = new EventEmitter();
|
|
274
|
+
const formBlock = {
|
|
275
|
+
uid: 'form-1',
|
|
276
|
+
disposed: false,
|
|
277
|
+
emitter,
|
|
278
|
+
context: {
|
|
279
|
+
form: {},
|
|
280
|
+
formValues: {
|
|
281
|
+
org_m2o: { id: 1 },
|
|
282
|
+
staff_m2o: { id: 10 },
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const onChange = vi.fn();
|
|
288
|
+
const model: any = {
|
|
289
|
+
disposed: false,
|
|
290
|
+
props: {
|
|
291
|
+
value: { id: 10 },
|
|
292
|
+
onChange,
|
|
293
|
+
},
|
|
294
|
+
context: {
|
|
295
|
+
blockModel: formBlock,
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const ctx: any = {
|
|
300
|
+
model,
|
|
301
|
+
flowKey: 'selectSettings',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const filter = {
|
|
305
|
+
logic: '$and',
|
|
306
|
+
items: [{ path: 'orgId', operator: '$eq', value: '{{ ctx.item.value.org_m2o.id }}' }],
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
ensureFormValueDrivenDataScopeClear(ctx, filter);
|
|
310
|
+
|
|
311
|
+
emitter.emit('formValuesChange', {
|
|
312
|
+
changedPaths: [['staff_m2o']],
|
|
313
|
+
allValues: {
|
|
314
|
+
org_m2o: { id: 1 },
|
|
315
|
+
staff_m2o: { id: 10 },
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
319
|
+
|
|
320
|
+
emitter.emit('formValuesChange', {
|
|
321
|
+
changedPaths: [['org_m2o']],
|
|
322
|
+
allValues: {
|
|
323
|
+
org_m2o: { id: 2 },
|
|
324
|
+
staff_m2o: { id: 10 },
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
expect(onChange).toHaveBeenCalledWith(null);
|
|
328
|
+
});
|
|
329
|
+
|
|
228
330
|
it('does not clear a row field when a sibling field changes but the item dependency value is unchanged', () => {
|
|
229
331
|
const emitter = new EventEmitter();
|
|
230
332
|
const formBlock = {
|
|
@@ -2174,17 +2174,30 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
|
|
|
2174
2174
|
}
|
|
2175
2175
|
: props;
|
|
2176
2176
|
|
|
2177
|
-
//
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2177
|
+
// 只记录联动实际控制的属性,避免恢复状态时覆盖标题等无关的最新配置。
|
|
2178
|
+
const originalProps = model.__originalProps || (model.__originalProps = {});
|
|
2179
|
+
const rememberOriginalProp = (key: string, value: unknown) => {
|
|
2180
|
+
if (!Object.prototype.hasOwnProperty.call(originalProps, key)) {
|
|
2181
|
+
originalProps[key] = value;
|
|
2182
|
+
}
|
|
2183
|
+
};
|
|
2184
|
+
Object.keys(normalizedProps || {}).forEach((key) => {
|
|
2185
|
+
if (key === 'hiddenModel') {
|
|
2186
|
+
rememberOriginalProp(
|
|
2187
|
+
key,
|
|
2188
|
+
Object.prototype.hasOwnProperty.call(model.props || {}, key) ? model.props?.[key] : model.hidden,
|
|
2189
|
+
);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
rememberOriginalProp(key, model.props?.[key]);
|
|
2194
|
+
if (key === 'hiddenText' && normalizedProps[key]) {
|
|
2195
|
+
rememberOriginalProp('title', model.props?.title);
|
|
2196
|
+
}
|
|
2197
|
+
if (key === 'required') {
|
|
2198
|
+
rememberOriginalProp('rules', model.props?.rules);
|
|
2199
|
+
}
|
|
2200
|
+
});
|
|
2188
2201
|
|
|
2189
2202
|
// 临时存起来,遍历完所有规则后,再统一处理
|
|
2190
2203
|
patchPropsByModel.set(model, {
|
|
@@ -2242,7 +2255,6 @@ const commonLinkageRulesHandler = async (ctx: FlowContext, params: any) => {
|
|
|
2242
2255
|
const newProps = { ...model.__originalProps, ...patchProps };
|
|
2243
2256
|
const prevHidden = !!model.hidden;
|
|
2244
2257
|
const nextHidden = !!newProps.hiddenModel;
|
|
2245
|
-
|
|
2246
2258
|
model.setProps(_.omit(newProps, ['hiddenModel', 'value', 'hiddenText', LINKAGE_ASSIGN_MODE_PROP]));
|
|
2247
2259
|
syncFieldOptionsToForks(model, patchProps);
|
|
2248
2260
|
if (typeof model.setHidden === 'function') {
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { type FlowEngine, useFlowContext, useFlowEngine } from '@nocobase/flow-engine';
|
|
11
|
+
import { Button, Result } from 'antd';
|
|
11
12
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
12
|
-
import {
|
|
13
|
+
import { useTranslation } from 'react-i18next';
|
|
14
|
+
import { useLocation, useParams } from 'react-router-dom';
|
|
15
|
+
import { getModernClientPrefix, stripModernClientPrefix } from '../../authRedirect';
|
|
13
16
|
import { useApp } from '../../hooks/useApp';
|
|
14
17
|
import { NocoBaseDesktopRouteType } from '../../flow-compat';
|
|
15
18
|
import { resolveAdminRouteRuntimeTarget } from '../admin-shell/admin-layout/resolveAdminRouteRuntimeTarget';
|
|
@@ -24,6 +27,7 @@ type FlowRouteGuardState = {
|
|
|
24
27
|
pending: boolean;
|
|
25
28
|
allowBridge: boolean;
|
|
26
29
|
notFound: boolean;
|
|
30
|
+
legacyPageUnsupported?: boolean;
|
|
27
31
|
};
|
|
28
32
|
|
|
29
33
|
export type LegacyPageBehavior = 'redirect' | 'notFound' | 'bridge';
|
|
@@ -111,6 +115,53 @@ const BridgeFlowRoute = ({
|
|
|
111
115
|
return <div ref={layoutContentRef} />;
|
|
112
116
|
};
|
|
113
117
|
|
|
118
|
+
type RouteLocation = {
|
|
119
|
+
pathname: string;
|
|
120
|
+
search: string;
|
|
121
|
+
hash: string;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const getLegacyPageHref = (app: { getPublicPath: () => string }, location: RouteLocation) => {
|
|
125
|
+
const modernPublicPath = app.getPublicPath();
|
|
126
|
+
const browserLocationMatchesPublicPath = window.location.pathname.startsWith(modernPublicPath);
|
|
127
|
+
const currentLocation = browserLocationMatchesPublicPath ? window.location : location;
|
|
128
|
+
const pathWithinModernClient = currentLocation.pathname.startsWith(modernPublicPath)
|
|
129
|
+
? currentLocation.pathname.slice(modernPublicPath.length)
|
|
130
|
+
: currentLocation.pathname.replace(/^\/+/, '');
|
|
131
|
+
return `${stripModernClientPrefix(modernPublicPath)}${pathWithinModernClient}${currentLocation.search}${
|
|
132
|
+
currentLocation.hash
|
|
133
|
+
}`;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const LegacyPageUnsupported = ({
|
|
137
|
+
app,
|
|
138
|
+
location,
|
|
139
|
+
}: {
|
|
140
|
+
app: { getPublicPath: () => string };
|
|
141
|
+
location: RouteLocation;
|
|
142
|
+
}) => {
|
|
143
|
+
const { t } = useTranslation();
|
|
144
|
+
const modernClientPath = `/${getModernClientPrefix()}/`;
|
|
145
|
+
const withModernClientPath = (message: string) => message.replaceAll('{{modernClientPath}}', modernClientPath);
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Result
|
|
149
|
+
status="warning"
|
|
150
|
+
title={withModernClientPath(t('This page is not supported in the {{modernClientPath}} branch'))}
|
|
151
|
+
subTitle={withModernClientPath(
|
|
152
|
+
t(
|
|
153
|
+
'The {{modernClientPath}} branch only supports new pages. This page is a legacy page. Please open it from the original entry.',
|
|
154
|
+
),
|
|
155
|
+
)}
|
|
156
|
+
extra={
|
|
157
|
+
<Button href={getLegacyPageHref(app, location)} type="primary">
|
|
158
|
+
{t('Open from the original entry')}
|
|
159
|
+
</Button>
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
114
165
|
/**
|
|
115
166
|
* 管理后台动态页面路由组件。
|
|
116
167
|
*
|
|
@@ -136,6 +187,7 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
136
187
|
const legacyPageBehavior = props.legacyPageBehavior || getDefaultLegacyPageBehavior(flowEngine, contextLayout);
|
|
137
188
|
const app = useApp();
|
|
138
189
|
const routeRepository = flowEngine.context.routeRepository;
|
|
190
|
+
const location = useLocation();
|
|
139
191
|
const params = useParams();
|
|
140
192
|
const pageUid = props.pageUid || params?.name;
|
|
141
193
|
const skipRouteRepositoryCheck = !routeRepository;
|
|
@@ -174,7 +226,9 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
174
226
|
}
|
|
175
227
|
|
|
176
228
|
const route = skipRouteRepositoryCheck ? undefined : routeRepository?.getRouteBySchemaUid?.(pageUid);
|
|
177
|
-
|
|
229
|
+
const shouldCheckFlowModel =
|
|
230
|
+
legacyPageBehavior === 'notFound' || (!skipRouteRepositoryCheck && legacyPageBehavior === 'redirect');
|
|
231
|
+
if (!route && shouldCheckFlowModel) {
|
|
178
232
|
const flowModelExists = await hasFlowModel(flowEngine, pageUid);
|
|
179
233
|
if (active && requestId === requestIdRef.current) {
|
|
180
234
|
setGuardState({ pending: false, allowBridge: flowModelExists, notFound: !flowModelExists });
|
|
@@ -212,7 +266,7 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
212
266
|
|
|
213
267
|
if (target.reason === 'unsupportedV2Runtime') {
|
|
214
268
|
if (active && requestId === requestIdRef.current) {
|
|
215
|
-
setGuardState({ pending: false, allowBridge: false, notFound: true });
|
|
269
|
+
setGuardState({ pending: false, allowBridge: false, notFound: false, legacyPageUnsupported: true });
|
|
216
270
|
}
|
|
217
271
|
return;
|
|
218
272
|
}
|
|
@@ -239,12 +293,26 @@ const FlowRoute = (props: FlowRouteProps = {}) => {
|
|
|
239
293
|
return <AppNotFound />;
|
|
240
294
|
}
|
|
241
295
|
|
|
296
|
+
if (guardState.legacyPageUnsupported) {
|
|
297
|
+
return <LegacyPageUnsupported app={app} location={location} />;
|
|
298
|
+
}
|
|
299
|
+
|
|
242
300
|
if (!guardState.allowBridge) {
|
|
243
301
|
return null;
|
|
244
302
|
}
|
|
245
303
|
|
|
246
304
|
return <BridgeFlowRoute pageUid={pageUid} active={props.active} getLayoutModel={getLayoutModel} />;
|
|
247
|
-
}, [
|
|
305
|
+
}, [
|
|
306
|
+
app,
|
|
307
|
+
getLayoutModel,
|
|
308
|
+
guardState.allowBridge,
|
|
309
|
+
guardState.legacyPageUnsupported,
|
|
310
|
+
guardState.notFound,
|
|
311
|
+
guardState.pending,
|
|
312
|
+
location,
|
|
313
|
+
pageUid,
|
|
314
|
+
props.active,
|
|
315
|
+
]);
|
|
248
316
|
|
|
249
317
|
return content;
|
|
250
318
|
};
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
FlowSettingsButton,
|
|
15
15
|
buildSubModelGroups,
|
|
16
16
|
buildSubModelItems,
|
|
17
|
+
type FlowModel,
|
|
17
18
|
type SubModelItem,
|
|
18
19
|
type SubModelItemsType,
|
|
20
|
+
VIEW_ACTIVATED_EVENT,
|
|
19
21
|
} from '@nocobase/flow-engine';
|
|
20
22
|
import React from 'react';
|
|
21
23
|
import { FilterManager } from '../blocks/filter-manager/FilterManager';
|
|
@@ -24,6 +26,8 @@ import { GridModel } from './GridModel';
|
|
|
24
26
|
const SELECT_SCENE_ALLOWED_OTHER_BLOCK_MODELS = ['JSBlockModel', 'IframeBlockModel', 'MarkdownBlockModel'] as const;
|
|
25
27
|
|
|
26
28
|
export class BlockGridModel extends GridModel {
|
|
29
|
+
private viewActivatedListener?: () => void;
|
|
30
|
+
|
|
27
31
|
dragOverlayConfig: DragOverlayConfig = {
|
|
28
32
|
// 列内插入
|
|
29
33
|
columnInsert: {
|
|
@@ -50,6 +54,28 @@ export class BlockGridModel extends GridModel {
|
|
|
50
54
|
});
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
onMount() {
|
|
58
|
+
super.onMount();
|
|
59
|
+
if (this.context.view?.inputArgs?.scene !== 'select' || this.viewActivatedListener) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.viewActivatedListener = () => {
|
|
64
|
+
this.mapSubModels('items', (item) => {
|
|
65
|
+
(item as FlowModel & { onActive?: () => void }).onActive?.();
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
this.flowEngine.emitter.on(VIEW_ACTIVATED_EVENT, this.viewActivatedListener);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected onUnmount() {
|
|
72
|
+
if (this.viewActivatedListener) {
|
|
73
|
+
this.flowEngine.emitter.off(VIEW_ACTIVATED_EVENT, this.viewActivatedListener);
|
|
74
|
+
this.viewActivatedListener = undefined;
|
|
75
|
+
}
|
|
76
|
+
super.onUnmount();
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
get subModelBaseClasses() {
|
|
54
80
|
const inputArgs = this.context.view?.inputArgs ?? {};
|
|
55
81
|
if (inputArgs.collectionName && !inputArgs.filterByTk) {
|
|
@@ -0,0 +1,124 @@
|
|
|
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 { FlowEngine, type FlowModelContext, MultiRecordResource, VIEW_ACTIVATED_EVENT } from '@nocobase/flow-engine';
|
|
11
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
12
|
+
import { BlockGridModel } from '../BlockGridModel';
|
|
13
|
+
import { CollectionBlockModel } from '../CollectionBlockModel';
|
|
14
|
+
|
|
15
|
+
class TestBlockGridModel extends BlockGridModel {
|
|
16
|
+
mountForTest() {
|
|
17
|
+
super.onMount();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
unmountForTest() {
|
|
21
|
+
super.onUnmount();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class TestMultiRecordResource extends MultiRecordResource<Record<string, unknown>> {
|
|
26
|
+
refreshCalls = 0;
|
|
27
|
+
|
|
28
|
+
override async refresh(): Promise<void> {
|
|
29
|
+
this.refreshCalls += 1;
|
|
30
|
+
this.emit('refresh');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class TestCollectionBlockModel extends CollectionBlockModel {
|
|
35
|
+
createResource(ctx: FlowModelContext) {
|
|
36
|
+
return ctx.createResource(TestMultiRecordResource);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
mountForTest() {
|
|
40
|
+
super.onMount();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('BlockGridModel - select scene activation', () => {
|
|
45
|
+
let engine: FlowEngine;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
engine = new FlowEngine();
|
|
49
|
+
engine.context.defineProperty('location', { value: { search: '' } });
|
|
50
|
+
engine.registerModels({ TestBlockGridModel, TestCollectionBlockModel });
|
|
51
|
+
|
|
52
|
+
engine.dataSourceManager.getDataSource('main').addCollection({
|
|
53
|
+
name: 'roles',
|
|
54
|
+
filterTargetKey: 'id',
|
|
55
|
+
fields: [
|
|
56
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
57
|
+
{ name: 'name', type: 'string', interface: 'input' },
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function createModels(scene = 'select') {
|
|
63
|
+
engine.context.defineProperty('view', { value: { inputArgs: { scene } } });
|
|
64
|
+
const grid = engine.createModel<TestBlockGridModel>({ use: 'TestBlockGridModel' });
|
|
65
|
+
const block = grid.addSubModel<TestCollectionBlockModel>('items', {
|
|
66
|
+
use: 'TestCollectionBlockModel',
|
|
67
|
+
stepParams: {
|
|
68
|
+
resourceSettings: {
|
|
69
|
+
init: {
|
|
70
|
+
dataSourceKey: 'main',
|
|
71
|
+
collectionName: 'roles',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const resource = block.context.resource as TestMultiRecordResource;
|
|
77
|
+
|
|
78
|
+
block.mountForTest();
|
|
79
|
+
grid.mountForTest();
|
|
80
|
+
return { grid, resource };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
it('refreshes dirty collection blocks once when a nested view closes', async () => {
|
|
84
|
+
const { grid, resource } = createModels();
|
|
85
|
+
|
|
86
|
+
engine.markDataSourceDirty('main', 'roles');
|
|
87
|
+
engine.emitter.emit(VIEW_ACTIVATED_EVENT);
|
|
88
|
+
await Promise.resolve();
|
|
89
|
+
|
|
90
|
+
expect(resource.refreshCalls).toBe(1);
|
|
91
|
+
|
|
92
|
+
engine.emitter.emit(VIEW_ACTIVATED_EVENT);
|
|
93
|
+
await Promise.resolve();
|
|
94
|
+
|
|
95
|
+
expect(resource.refreshCalls).toBe(1);
|
|
96
|
+
grid.unmountForTest();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('does not refresh without dirty data or after the grid unmounts', async () => {
|
|
100
|
+
const { grid, resource } = createModels();
|
|
101
|
+
|
|
102
|
+
engine.emitter.emit(VIEW_ACTIVATED_EVENT);
|
|
103
|
+
await Promise.resolve();
|
|
104
|
+
expect(resource.refreshCalls).toBe(0);
|
|
105
|
+
|
|
106
|
+
grid.unmountForTest();
|
|
107
|
+
engine.markDataSourceDirty('main', 'roles');
|
|
108
|
+
engine.emitter.emit(VIEW_ACTIVATED_EVENT);
|
|
109
|
+
await Promise.resolve();
|
|
110
|
+
|
|
111
|
+
expect(resource.refreshCalls).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not handle activation outside the select scene', async () => {
|
|
115
|
+
const { grid, resource } = createModels('list');
|
|
116
|
+
|
|
117
|
+
engine.markDataSourceDirty('main', 'roles');
|
|
118
|
+
engine.emitter.emit(VIEW_ACTIVATED_EVENT);
|
|
119
|
+
await Promise.resolve();
|
|
120
|
+
|
|
121
|
+
expect(resource.refreshCalls).toBe(0);
|
|
122
|
+
grid.unmountForTest();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -234,7 +234,9 @@ export class TableBlockModel extends CollectionBlockModel<TableBlockModelStructu
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
createResource(ctx, params) {
|
|
237
|
-
|
|
237
|
+
const resource = this.context.createResource(MultiRecordResource);
|
|
238
|
+
resource.addRequestHeader('X-With-ACL-Meta', 'true');
|
|
239
|
+
return resource;
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
/**
|
|
@@ -194,6 +194,35 @@ export class TableColumnModel extends DisplayItemModel {
|
|
|
194
194
|
.filter(Boolean);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
canQuickEdit(record: Record<string, unknown>): boolean {
|
|
198
|
+
if (!this.props.editable) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (this.context.skipAclCheck) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const blockModel = this.context.blockModel;
|
|
206
|
+
const collection = blockModel?.collection;
|
|
207
|
+
const resource = blockModel?.resource;
|
|
208
|
+
const collectionField = this.collectionField;
|
|
209
|
+
if (!collection || !resource || !collectionField) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const filterByTk = collection.getFilterByTK(record);
|
|
214
|
+
const recordPkValue = typeof filterByTk === 'string' || typeof filterByTk === 'number' ? filterByTk : undefined;
|
|
215
|
+
|
|
216
|
+
return this.context.acl.can({
|
|
217
|
+
dataSourceKey: collection.dataSourceKey,
|
|
218
|
+
resourceName: resource.getResourceName() || collection.name,
|
|
219
|
+
actionName: 'update',
|
|
220
|
+
recordPkValue,
|
|
221
|
+
allowedActions: resource.getMeta('allowedActions'),
|
|
222
|
+
fields: [collectionField.name],
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
197
226
|
getColumnProps(): TableColumnProps & { sortField?: string } {
|
|
198
227
|
if (!this.props.width) {
|
|
199
228
|
return;
|
|
@@ -250,7 +279,7 @@ export class TableColumnModel extends DisplayItemModel {
|
|
|
250
279
|
record,
|
|
251
280
|
recordIndex: record?.__index || recordIndex,
|
|
252
281
|
width: this.props.width - 16,
|
|
253
|
-
editable: this.
|
|
282
|
+
editable: this.canQuickEdit(record),
|
|
254
283
|
dataIndex: this.props.dataIndex,
|
|
255
284
|
title: this.props.title,
|
|
256
285
|
overflowMode: this.props.overflowMode,
|
|
@@ -9,8 +9,20 @@
|
|
|
9
9
|
|
|
10
10
|
import { FlowEngine, MultiRecordResource } from '@nocobase/flow-engine';
|
|
11
11
|
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { TableBlockModel } from '../TableBlockModel';
|
|
12
13
|
|
|
13
14
|
describe('TableBlockModel quick edit refresh', () => {
|
|
15
|
+
it('requests record-level ACL metadata for table data', () => {
|
|
16
|
+
const engine = new FlowEngine();
|
|
17
|
+
const model = {
|
|
18
|
+
context: engine.context,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const resource = TableBlockModel.prototype.createResource.call(model, {}, {});
|
|
22
|
+
|
|
23
|
+
expect(resource.getRequestOptions().headers['X-With-ACL-Meta']).toBe('true');
|
|
24
|
+
});
|
|
25
|
+
|
|
14
26
|
it('updates table data through a new array reference after quick editing a flat row', () => {
|
|
15
27
|
const engine = new FlowEngine();
|
|
16
28
|
const resource = engine.context.createResource(MultiRecordResource);
|
|
@@ -66,6 +66,39 @@ describe('TableColumnModel sorter settings', () => {
|
|
|
66
66
|
expect(setProps).toHaveBeenCalledWith('editable', false);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it('checks record update scope and field permission before enabling quick edit', () => {
|
|
70
|
+
const can = vi.fn(() => false);
|
|
71
|
+
const model = {
|
|
72
|
+
props: { editable: true },
|
|
73
|
+
collectionField: { name: 'title' },
|
|
74
|
+
context: {
|
|
75
|
+
skipAclCheck: false,
|
|
76
|
+
acl: { can },
|
|
77
|
+
blockModel: {
|
|
78
|
+
collection: {
|
|
79
|
+
dataSourceKey: 'main',
|
|
80
|
+
name: 'posts',
|
|
81
|
+
getFilterByTK: () => 2,
|
|
82
|
+
},
|
|
83
|
+
resource: {
|
|
84
|
+
getResourceName: () => 'posts',
|
|
85
|
+
getMeta: () => ({ update: [1] }),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
expect(TableColumnModel.prototype.canQuickEdit.call(model, { id: 2 })).toBe(false);
|
|
92
|
+
expect(can).toHaveBeenCalledWith({
|
|
93
|
+
dataSourceKey: 'main',
|
|
94
|
+
resourceName: 'posts',
|
|
95
|
+
actionName: 'update',
|
|
96
|
+
recordPkValue: 2,
|
|
97
|
+
allowedActions: { update: [1] },
|
|
98
|
+
fields: ['title'],
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
69
102
|
it('hides sortable setting for association fields', async () => {
|
|
70
103
|
const engine = new FlowEngine();
|
|
71
104
|
const model = new TableColumnModel({ uid: 'table-column-association-sorter', flowEngine: engine } as any);
|