@nocobase/client-v2 2.2.0-beta.7 → 2.2.0-beta.8
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/flow/actions/afterSuccess.d.ts +8 -1
- package/es/flow/actions/index.d.ts +1 -1
- package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +1 -0
- package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +9 -0
- package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
- package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
- package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
- package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
- package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
- package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
- package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
- package/es/index.mjs +103 -87
- package/lib/index.js +105 -89
- package/package.json +7 -7
- package/src/collection-manager/field-configure.ts +1 -1
- package/src/collection-manager/interfaces/id.ts +1 -1
- package/src/collection-manager/interfaces/m2m.tsx +2 -2
- package/src/collection-manager/interfaces/m2o.tsx +2 -2
- package/src/collection-manager/interfaces/nanoid.ts +1 -1
- package/src/collection-manager/interfaces/o2m.tsx +2 -2
- package/src/collection-manager/interfaces/obo.tsx +2 -2
- package/src/collection-manager/interfaces/oho.tsx +2 -2
- package/src/collection-manager/interfaces/properties/index.ts +2 -2
- package/src/collection-manager/interfaces/uuid.ts +1 -1
- package/src/flow/__tests__/getKey.test.ts +7 -0
- package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
- package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
- package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +104 -0
- package/src/flow/actions/afterSuccess.tsx +142 -3
- package/src/flow/actions/index.ts +1 -1
- package/src/flow/actions/openView.tsx +38 -4
- package/src/flow/admin-shell/BaseLayoutModel.tsx +25 -3
- package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +5 -2
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +13 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +1 -0
- package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +5 -4
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +82 -0
- package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
- package/src/flow/models/base/ActionModelCore.tsx +6 -4
- package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
- package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
- package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
- package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
- package/src/flow/models/blocks/form/submitHandler.ts +17 -3
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +87 -0
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +91 -0
- package/src/flow/models/blocks/js-block/JSBlock.tsx +223 -2
- package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
- package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +35 -12
- package/src/flow/models/blocks/table/TableBlockModel.tsx +25 -14
- package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +57 -1
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
- package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
- package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
- package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
- package/src/flow/resolveViewParamsToViewList.tsx +11 -3
- package/src/settings-center/SystemSettingsPage.tsx +1 -1
|
@@ -7,7 +7,15 @@
|
|
|
7
7
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
defineAction,
|
|
12
|
+
tExpr,
|
|
13
|
+
FlowModelContext,
|
|
14
|
+
FlowModel,
|
|
15
|
+
FlowExitAllException,
|
|
16
|
+
createOpenViewRouteState,
|
|
17
|
+
RUNJS_OPEN_VIEW_ROUTE_STATE,
|
|
18
|
+
} from '@nocobase/flow-engine';
|
|
11
19
|
import React from 'react';
|
|
12
20
|
import { FlowPage } from '../FlowPage';
|
|
13
21
|
import { PageModel, RootPageModel } from '../models';
|
|
@@ -291,8 +299,32 @@ export const openView = defineAction({
|
|
|
291
299
|
? (inputArgs as any).associationName
|
|
292
300
|
: (params as any)?.associationName;
|
|
293
301
|
const mergedTabUid = typeof inputArgs.tabUid !== 'undefined' ? inputArgs.tabUid : params.tabUid;
|
|
294
|
-
|
|
295
|
-
const
|
|
302
|
+
const hasRunJSOpenViewRouteState = Object.prototype.hasOwnProperty.call(inputArgs, RUNJS_OPEN_VIEW_ROUTE_STATE);
|
|
303
|
+
const runJSOpenViewRouteStateInput = (inputArgs as Record<PropertyKey, unknown>)[RUNJS_OPEN_VIEW_ROUTE_STATE];
|
|
304
|
+
const runJSOpenViewRouteState = hasRunJSOpenViewRouteState
|
|
305
|
+
? createOpenViewRouteState(
|
|
306
|
+
runJSOpenViewRouteStateInput && typeof runJSOpenViewRouteStateInput === 'object'
|
|
307
|
+
? (runJSOpenViewRouteStateInput as { mode?: unknown; size?: unknown })
|
|
308
|
+
: {
|
|
309
|
+
mode: inputArgs.mode,
|
|
310
|
+
size: inputArgs.size,
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
: undefined;
|
|
314
|
+
const replayOpenViewRouteStateInput = (inputArgs as { openViewRouteState?: unknown }).openViewRouteState;
|
|
315
|
+
const replayOpenViewRouteState =
|
|
316
|
+
replayOpenViewRouteStateInput && typeof replayOpenViewRouteStateInput === 'object'
|
|
317
|
+
? createOpenViewRouteState(replayOpenViewRouteStateInput as { mode?: unknown; size?: unknown })
|
|
318
|
+
: undefined;
|
|
319
|
+
const runtimeOpenViewRouteState = runJSOpenViewRouteState || replayOpenViewRouteState;
|
|
320
|
+
// 移动端中只需要显示子页面。本次 dispatch 参数优先于持久化默认值。
|
|
321
|
+
const openMode = ctx.inputArgs?.isMobileLayout
|
|
322
|
+
? 'embed'
|
|
323
|
+
: runtimeOpenViewRouteState?.mode || ctx.inputArgs?.mode || params.mode || 'drawer';
|
|
324
|
+
const effectiveRunJSOpenViewRouteState =
|
|
325
|
+
runJSOpenViewRouteState && ctx.inputArgs?.isMobileLayout
|
|
326
|
+
? createOpenViewRouteState({ ...runJSOpenViewRouteState, mode: openMode })
|
|
327
|
+
: runJSOpenViewRouteState;
|
|
296
328
|
let navigation = typeof inputArgs.navigation !== 'undefined' ? inputArgs.navigation : params.navigation;
|
|
297
329
|
|
|
298
330
|
// 传递了上下文就必须禁用路由,否则下次路由打开会缺少上下文
|
|
@@ -313,6 +345,7 @@ export const openView = defineAction({
|
|
|
313
345
|
sourceId: mergedSourceId,
|
|
314
346
|
tabUid: mergedTabUid,
|
|
315
347
|
viewUid: ctx.model.context?.inputArgs?.viewUid || ctx.model.uid,
|
|
348
|
+
...(effectiveRunJSOpenViewRouteState ? { openViewRouteState: effectiveRunJSOpenViewRouteState } : {}),
|
|
316
349
|
} as Record<string, unknown>;
|
|
317
350
|
const pendingView = {
|
|
318
351
|
type: pendingType,
|
|
@@ -328,6 +361,7 @@ export const openView = defineAction({
|
|
|
328
361
|
filterByTk: mergedFilterByTk,
|
|
329
362
|
sourceId: mergedSourceId,
|
|
330
363
|
tabUid: mergedTabUid,
|
|
364
|
+
...(effectiveRunJSOpenViewRouteState ? { openViewRouteState: effectiveRunJSOpenViewRouteState } : {}),
|
|
331
365
|
};
|
|
332
366
|
ctx.view.navigation.navigateTo(nextView);
|
|
333
367
|
return;
|
|
@@ -373,7 +407,7 @@ export const openView = defineAction({
|
|
|
373
407
|
|
|
374
408
|
const pageModelClass =
|
|
375
409
|
ctx.inputArgs.pageModelClass || params.pageModelClass || ctx.layout?.childPageModelClass || 'ChildPageModel';
|
|
376
|
-
const size = ctx.inputArgs.size || params.size || 'medium';
|
|
410
|
+
const size = runtimeOpenViewRouteState?.size || ctx.inputArgs.size || params.size || 'medium';
|
|
377
411
|
let pageModelUid: string | null = null;
|
|
378
412
|
let pageModelRef: FlowModel | null = null;
|
|
379
413
|
|
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { define, observable } from '@formily/reactive';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
decodeOpenViewRouteState,
|
|
13
|
+
parsePathnameToViewParams,
|
|
14
|
+
type FlowEngine,
|
|
15
|
+
FlowModel,
|
|
16
|
+
type ViewParam,
|
|
17
|
+
} from '@nocobase/flow-engine';
|
|
12
18
|
import {
|
|
13
19
|
BaseLayoutRouteCoordinator,
|
|
14
20
|
type BaseLayoutRouteCoordinatorOptions,
|
|
@@ -116,20 +122,36 @@ const isStandardLayoutRelativePath = (relativePath: string) => {
|
|
|
116
122
|
}
|
|
117
123
|
|
|
118
124
|
let i = 1;
|
|
125
|
+
let currentViewUid = segments[0];
|
|
119
126
|
while (i < segments.length) {
|
|
120
127
|
const segment = segments[i];
|
|
128
|
+
|
|
121
129
|
if (segment === 'view') {
|
|
122
130
|
if (!segments[i + 1]) {
|
|
123
131
|
return false;
|
|
124
132
|
}
|
|
133
|
+
currentViewUid = segments[i + 1];
|
|
134
|
+
i += 2;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (segment === 'opts') {
|
|
139
|
+
if (!segments[i + 1] || !decodeOpenViewRouteState(currentViewUid, segments[i + 1])) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
i += 2;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (isKnownViewParamName(segment) && segments[i + 1]) {
|
|
125
147
|
i += 2;
|
|
126
148
|
continue;
|
|
127
149
|
}
|
|
128
150
|
|
|
129
|
-
if (!
|
|
151
|
+
if (!segments[i + 1]) {
|
|
130
152
|
return false;
|
|
131
153
|
}
|
|
132
|
-
|
|
154
|
+
return false;
|
|
133
155
|
}
|
|
134
156
|
|
|
135
157
|
return true;
|
|
@@ -272,7 +272,7 @@ export class BaseLayoutRouteCoordinator {
|
|
|
272
272
|
runtime.viewState[getKey(viewItem)]?.destroy?.(true);
|
|
273
273
|
delete runtime.viewState[getKey(viewItem)];
|
|
274
274
|
});
|
|
275
|
-
updateViewListHidden(viewList);
|
|
275
|
+
updateViewListHidden(viewList, !!this.layoutContext?.isMobileLayout);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
if (viewsToOpen.length) {
|
|
@@ -299,7 +299,7 @@ export class BaseLayoutRouteCoordinator {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
if (runtime.meta.active) {
|
|
302
|
-
updateViewListHidden(viewList);
|
|
302
|
+
updateViewListHidden(viewList, !!this.layoutContext?.isMobileLayout);
|
|
303
303
|
return;
|
|
304
304
|
}
|
|
305
305
|
|
|
@@ -390,6 +390,7 @@ export class BaseLayoutRouteCoordinator {
|
|
|
390
390
|
openViewParams?.associationName && !hasUsableSourceId(viewItem.params.sourceId)
|
|
391
391
|
? null
|
|
392
392
|
: openViewParams?.associationName;
|
|
393
|
+
const openViewRouteState = viewItem.params.openViewRouteState;
|
|
393
394
|
const openerUids = viewList.slice(0, viewItem.index).map((item) => item.params.viewUid);
|
|
394
395
|
const navigation = new ViewNavigation(
|
|
395
396
|
this.flowEngine.context,
|
|
@@ -408,6 +409,8 @@ export class BaseLayoutRouteCoordinator {
|
|
|
408
409
|
deactivateRef,
|
|
409
410
|
openerUids,
|
|
410
411
|
...viewItem.params,
|
|
412
|
+
...(openViewRouteState?.mode ? { mode: openViewRouteState.mode } : {}),
|
|
413
|
+
...(openViewRouteState?.size ? { size: openViewRouteState.size } : {}),
|
|
411
414
|
pageActive: runtime.meta.active,
|
|
412
415
|
activationControlledByLayout: true,
|
|
413
416
|
navigation,
|
|
@@ -121,6 +121,19 @@ describe('AdminLayoutRouteCoordinator', () => {
|
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it('passes RunJS openView route state as runtime mode and size during route replay', () => {
|
|
125
|
+
const { dispatchEvent } = setupRouteReplay({
|
|
126
|
+
openViewRouteState: { mode: 'dialog', size: 'large' },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
|
|
130
|
+
mode: 'dialog',
|
|
131
|
+
size: 'large',
|
|
132
|
+
openViewRouteState: { mode: 'dialog', size: 'large' },
|
|
133
|
+
triggerByRouter: true,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
124
137
|
it('uses layout content element before route page placeholder as view target', () => {
|
|
125
138
|
const engine = new FlowEngine();
|
|
126
139
|
engine.registerModels({ RouteModel });
|
|
@@ -718,6 +718,7 @@ export const AdminLayoutComponent = observer((props: any) => {
|
|
|
718
718
|
<ConfigProvider theme={isMobile ? mobileTheme : theme}>
|
|
719
719
|
<GlobalStyle />
|
|
720
720
|
<AdminLayoutContent
|
|
721
|
+
designable={designable}
|
|
721
722
|
layout={adminLayoutModel?.layout}
|
|
722
723
|
onContentElementChange={handleLayoutContentElementChange}
|
|
723
724
|
/>
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type AdminLayoutContentProps = {
|
|
27
27
|
onContentElementChange?: (element: HTMLDivElement | null) => void;
|
|
28
28
|
layout?: (AdminLayoutRoutePathLike & { routeName?: string }) | null;
|
|
29
|
+
designable?: boolean;
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
const layoutContentClass = css`
|
|
@@ -70,7 +71,7 @@ function isDvhSupported() {
|
|
|
70
71
|
return testEl.style.height === '1dvh';
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
const ShowTipWhenNoPages = observer((props: { layout?: AdminLayoutRoutePathLike | null }) => {
|
|
74
|
+
const ShowTipWhenNoPages = observer((props: { designable?: boolean; layout?: AdminLayoutRoutePathLike | null }) => {
|
|
74
75
|
const flowEngine = useFlowEngine();
|
|
75
76
|
const { token } = antdTheme.useToken();
|
|
76
77
|
const { t } = useTranslation();
|
|
@@ -79,7 +80,7 @@ const ShowTipWhenNoPages = observer((props: { layout?: AdminLayoutRoutePathLike
|
|
|
79
80
|
const visibleRoutes = isV2AdminRuntime(flowEngine.context.app)
|
|
80
81
|
? allAccessRoutes.filter((route) => isV2MenuRoute(route))
|
|
81
82
|
: allAccessRoutes;
|
|
82
|
-
const designable = !!flowEngine.context.flowSettingsEnabled;
|
|
83
|
+
const designable = !!props.designable || !!flowEngine.context.flowSettingsEnabled;
|
|
83
84
|
const layoutRoutePath = getAdminLayoutRoutePath(props.layout);
|
|
84
85
|
|
|
85
86
|
if (
|
|
@@ -104,7 +105,7 @@ const ShowTipWhenNoPages = observer((props: { layout?: AdminLayoutRoutePathLike
|
|
|
104
105
|
*
|
|
105
106
|
* 内容区不再依赖独立 FlowModel,而是通过回调把挂载目标同步给 root model。
|
|
106
107
|
*/
|
|
107
|
-
export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentElementChange, layout }) => {
|
|
108
|
+
export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ designable, onContentElementChange, layout }) => {
|
|
108
109
|
const style = useMemo(() => (isDvhSupported() ? mobileHeight : undefined), []);
|
|
109
110
|
const params = useParams();
|
|
110
111
|
const matches = useMatches();
|
|
@@ -131,7 +132,7 @@ export const AdminLayoutContent: FC<AdminLayoutContentProps> = ({ onContentEleme
|
|
|
131
132
|
>
|
|
132
133
|
<div style={pageContentStyle}>
|
|
133
134
|
{shouldKeepAlive && pageUid ? <KeepAlive uid={pageUid}>{() => <Outlet />}</KeepAlive> : <Outlet />}
|
|
134
|
-
<ShowTipWhenNoPages layout={layout} />
|
|
135
|
+
<ShowTipWhenNoPages designable={designable} layout={layout} />
|
|
135
136
|
</div>
|
|
136
137
|
</div>
|
|
137
138
|
);
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
FlowEngine,
|
|
40
40
|
FlowEngineProvider,
|
|
41
41
|
FlowModelRenderer,
|
|
42
|
+
encodeOpenViewRouteState,
|
|
42
43
|
useFlowEngine,
|
|
43
44
|
type FlowModel,
|
|
44
45
|
} from '@nocobase/flow-engine';
|
|
@@ -213,6 +214,87 @@ describe('AdminLayoutModel runtime', () => {
|
|
|
213
214
|
});
|
|
214
215
|
});
|
|
215
216
|
|
|
217
|
+
it('should parse RunJS openView route params into layout route view stack state', async () => {
|
|
218
|
+
const token = encodeOpenViewRouteState('popup', { mode: 'dialog', size: 'large' });
|
|
219
|
+
if (!token) {
|
|
220
|
+
throw new Error('Expected openView route state token.');
|
|
221
|
+
}
|
|
222
|
+
const engine = new FlowEngine();
|
|
223
|
+
engine.context.defineProperty('routeRepository', {
|
|
224
|
+
value: {
|
|
225
|
+
getRouteBySchemaUid: (pageUid: string) => ({ title: pageUid }),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
render(
|
|
230
|
+
<FlowEngineProvider engine={engine}>
|
|
231
|
+
<TestAdminLayoutHost />
|
|
232
|
+
</FlowEngineProvider>,
|
|
233
|
+
);
|
|
234
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
235
|
+
expect(model).toBeTruthy();
|
|
236
|
+
|
|
237
|
+
act(() => {
|
|
238
|
+
model.syncLayoutRoute({
|
|
239
|
+
name: getLayoutPageViewRouteName('admin'),
|
|
240
|
+
pathname: `/admin/page-1/view/popup/opts/${token}/filterbytk/1`,
|
|
241
|
+
layoutBasePathname: '/admin',
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await waitFor(() => {
|
|
246
|
+
expect(model.context.layoutRoute).toMatchObject({
|
|
247
|
+
type: 'page',
|
|
248
|
+
pageUid: 'page-1',
|
|
249
|
+
viewStack: [
|
|
250
|
+
{ viewUid: 'page-1' },
|
|
251
|
+
{
|
|
252
|
+
viewUid: 'popup',
|
|
253
|
+
openViewRouteState: { mode: 'dialog', size: 'large' },
|
|
254
|
+
filterByTk: '1',
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should reject malformed RunJS openView route params', async () => {
|
|
262
|
+
const wrongViewToken = encodeOpenViewRouteState('other-popup', { mode: 'dialog', size: 'large' });
|
|
263
|
+
if (!wrongViewToken) {
|
|
264
|
+
throw new Error('Expected openView route state token.');
|
|
265
|
+
}
|
|
266
|
+
const engine = new FlowEngine();
|
|
267
|
+
|
|
268
|
+
render(
|
|
269
|
+
<FlowEngineProvider engine={engine}>
|
|
270
|
+
<TestAdminLayoutHost />
|
|
271
|
+
</FlowEngineProvider>,
|
|
272
|
+
);
|
|
273
|
+
const model = engine.getModel<TestAdminLayoutModel>('admin-layout-model');
|
|
274
|
+
expect(model).toBeTruthy();
|
|
275
|
+
|
|
276
|
+
[
|
|
277
|
+
'/admin/page-1/sourceid',
|
|
278
|
+
'/admin/page-1/AbCdEfGh/filterbytk/1',
|
|
279
|
+
'/admin/page-1/view/popup/AbCdEfGh/filterbytk/1',
|
|
280
|
+
'/admin/page-1/view/popup/opts/AbCdEfGh/filterbytk/1',
|
|
281
|
+
'/admin/page-1/view/popup/openviewmode/dialog',
|
|
282
|
+
'/admin/page-1/view/popup/openviewsize/large',
|
|
283
|
+
`/admin/page-1/view/popup/opts/${wrongViewToken}/filterbytk/1`,
|
|
284
|
+
].forEach((pathname) => {
|
|
285
|
+
expect(
|
|
286
|
+
model.resolveLayoutRoute({
|
|
287
|
+
name: getLayoutPageViewRouteName('admin'),
|
|
288
|
+
pathname,
|
|
289
|
+
layoutBasePathname: '/admin',
|
|
290
|
+
}),
|
|
291
|
+
).toMatchObject({
|
|
292
|
+
type: 'notFound',
|
|
293
|
+
pathname,
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
216
298
|
it('should not consume global routes that belong to nested layouts', async () => {
|
|
217
299
|
const engine = new FlowEngine();
|
|
218
300
|
const routeRef = observable.ref({
|
|
@@ -25,7 +25,7 @@ export function ActionWithoutPermission(props) {
|
|
|
25
25
|
const dataSourcePrefix = `${t(dataSource.displayName || dataSource.key)} > `;
|
|
26
26
|
const collectionPrefix = collection ? `${t(collection.title) || collection.name || collection.tableName} ` : '';
|
|
27
27
|
return `${dataSourcePrefix}${collectionPrefix}`;
|
|
28
|
-
}, []);
|
|
28
|
+
}, [collection, dataSource.displayName, dataSource.key, t]);
|
|
29
29
|
const { actionName } = props?.forbidden || model.forbidden;
|
|
30
30
|
const messageValue = useMemo(() => {
|
|
31
31
|
return t(
|
|
@@ -143,10 +143,11 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
|
|
|
143
143
|
renderButton() {
|
|
144
144
|
const { iconOnly, ...props } = this.props;
|
|
145
145
|
const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
|
|
146
|
+
const titleContent = iconOnly && icon ? null : props.children || this.getTitle();
|
|
146
147
|
|
|
147
148
|
return (
|
|
148
149
|
<Button {...props} onClick={this.onClick.bind(this)} icon={icon}>
|
|
149
|
-
{
|
|
150
|
+
{titleContent}
|
|
150
151
|
</Button>
|
|
151
152
|
);
|
|
152
153
|
}
|
|
@@ -162,11 +163,12 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
|
|
|
162
163
|
renderHiddenInConfig(): React.ReactNode | undefined {
|
|
163
164
|
const { iconOnly, ...props } = this.props;
|
|
164
165
|
const icon = this.getIcon() ? <Icon type={this.getIcon() as any} /> : undefined;
|
|
166
|
+
const titleContent = iconOnly && icon ? null : props.children || this.getTitle();
|
|
165
167
|
if (this.forbidden) {
|
|
166
168
|
return (
|
|
167
169
|
<ActionWithoutPermission>
|
|
168
170
|
<Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
|
|
169
|
-
{
|
|
171
|
+
{titleContent}
|
|
170
172
|
</Button>
|
|
171
173
|
</ActionWithoutPermission>
|
|
172
174
|
);
|
|
@@ -174,7 +176,7 @@ export class ActionModel<T extends DefaultStructure = DefaultStructure> extends
|
|
|
174
176
|
return (
|
|
175
177
|
<Tooltip title={this.context.t('The button is hidden and only visible when the UI Editor is active')}>
|
|
176
178
|
<Button {...props} onClick={this.onClick.bind(this)} icon={icon} style={{ opacity: '0.3' }}>
|
|
177
|
-
{
|
|
179
|
+
{titleContent}
|
|
178
180
|
</Button>
|
|
179
181
|
</Tooltip>
|
|
180
182
|
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { render, screen } from '@nocobase/test/client';
|
|
2
|
+
import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
|
|
3
|
+
import { App, ConfigProvider } from 'antd';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import { ActionModel } from '../ActionModel';
|
|
7
|
+
|
|
8
|
+
class NoIconActionModel extends ActionModel {
|
|
9
|
+
defaultProps = {
|
|
10
|
+
type: 'link' as const,
|
|
11
|
+
title: 'Open details',
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('ActionModel rendering', () => {
|
|
16
|
+
it('shows the title when iconOnly is true but no icon is configured', () => {
|
|
17
|
+
const engine = new FlowEngine();
|
|
18
|
+
engine.registerModels({ NoIconActionModel });
|
|
19
|
+
const model = engine.createModel<NoIconActionModel>({
|
|
20
|
+
use: 'NoIconActionModel',
|
|
21
|
+
props: {
|
|
22
|
+
title: 'Open details',
|
|
23
|
+
iconOnly: true,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
render(
|
|
28
|
+
<FlowEngineProvider engine={engine}>
|
|
29
|
+
<ConfigProvider>
|
|
30
|
+
<App>{model.render()}</App>
|
|
31
|
+
</ConfigProvider>
|
|
32
|
+
</FlowEngineProvider>,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(screen.getByRole('button', { name: 'Open details' })).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -210,7 +210,7 @@ FilterFormCustomFieldModel.registerFlow({
|
|
|
210
210
|
'x-decorator': 'FormItem',
|
|
211
211
|
required: true,
|
|
212
212
|
description:
|
|
213
|
-
'{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with
|
|
213
|
+
'{{t("Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.")}}',
|
|
214
214
|
},
|
|
215
215
|
source: {
|
|
216
216
|
type: 'array',
|
|
@@ -117,7 +117,7 @@ FormSubmitActionModel.registerFlow({
|
|
|
117
117
|
try {
|
|
118
118
|
ctx.model.setProps('loading', true);
|
|
119
119
|
const { submitHandler } = await import('./submitHandler');
|
|
120
|
-
await submitHandler(ctx, params);
|
|
120
|
+
return await submitHandler(ctx, params);
|
|
121
121
|
} catch (error) {
|
|
122
122
|
ctx.model.setProps('loading', false);
|
|
123
123
|
if (error instanceof FlowExitAllException) {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { SingleRecordResource } from '@nocobase/flow-engine';
|
|
11
11
|
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import { EditFormModel } from '../EditFormModel';
|
|
12
13
|
import { submitHandler } from '../submitHandler';
|
|
13
14
|
|
|
14
15
|
describe('submitHandler', () => {
|
|
@@ -52,13 +53,14 @@ describe('submitHandler', () => {
|
|
|
52
53
|
t: (value: string) => value,
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
await submitHandler(ctx, {
|
|
56
|
+
const responseRecord = await submitHandler(ctx, {
|
|
56
57
|
assignedValues: {
|
|
57
58
|
status: 'published',
|
|
58
59
|
reviewer: 'Alice',
|
|
59
60
|
},
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
expect(responseRecord).toEqual({ id: 1 });
|
|
62
64
|
expect(resource.save).toHaveBeenCalledWith(
|
|
63
65
|
{
|
|
64
66
|
title: 'Draft title',
|
|
@@ -68,4 +70,55 @@ describe('submitHandler', () => {
|
|
|
68
70
|
undefined,
|
|
69
71
|
);
|
|
70
72
|
});
|
|
73
|
+
|
|
74
|
+
it('returns the refreshed record for edit forms when the save response is empty', async () => {
|
|
75
|
+
const refreshedRecord = { id: 2, title: 'Updated title' };
|
|
76
|
+
const resource = Object.create(SingleRecordResource.prototype);
|
|
77
|
+
resource.getMeta = vi.fn((key: string) => (key === 'currentFilterByTk' ? 2 : undefined));
|
|
78
|
+
resource.setFilterByTk = vi.fn(() => resource);
|
|
79
|
+
resource.save = vi.fn(async () => undefined);
|
|
80
|
+
resource.refresh = vi.fn(async () => undefined);
|
|
81
|
+
resource.getData = vi.fn(() => refreshedRecord);
|
|
82
|
+
|
|
83
|
+
const blockModel = Object.create(EditFormModel.prototype);
|
|
84
|
+
const form = {
|
|
85
|
+
validateFields: vi.fn(async () => undefined),
|
|
86
|
+
getFieldsValue: vi.fn(() => ({
|
|
87
|
+
title: 'Updated title',
|
|
88
|
+
})),
|
|
89
|
+
};
|
|
90
|
+
Object.defineProperty(blockModel, 'context', { value: { form } });
|
|
91
|
+
Object.defineProperty(blockModel, 'collection', {
|
|
92
|
+
value: {
|
|
93
|
+
name: 'posts',
|
|
94
|
+
getFilterByTK: (record: { id?: number }) => record.id,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
blockModel.resetUserModifiedFields = vi.fn();
|
|
98
|
+
|
|
99
|
+
const responseRecord = await submitHandler(
|
|
100
|
+
{
|
|
101
|
+
resource,
|
|
102
|
+
blockModel,
|
|
103
|
+
model: {
|
|
104
|
+
getStepParams: vi.fn(),
|
|
105
|
+
},
|
|
106
|
+
message: {
|
|
107
|
+
error: vi.fn(),
|
|
108
|
+
},
|
|
109
|
+
t: (value: string) => value,
|
|
110
|
+
},
|
|
111
|
+
{},
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(responseRecord).toBe(refreshedRecord);
|
|
115
|
+
expect(resource.setFilterByTk).toHaveBeenCalledWith(2);
|
|
116
|
+
expect(resource.save).toHaveBeenCalledWith(
|
|
117
|
+
{
|
|
118
|
+
title: 'Updated title',
|
|
119
|
+
},
|
|
120
|
+
undefined,
|
|
121
|
+
);
|
|
122
|
+
expect(resource.refresh).toHaveBeenCalled();
|
|
123
|
+
});
|
|
71
124
|
});
|
|
@@ -12,6 +12,16 @@ import { mergeAssignFieldValues, resolveAssignFieldValues } from '../assign-form
|
|
|
12
12
|
import type { FormBlockModel } from './FormBlockModel';
|
|
13
13
|
import { omitHiddenModelValuesFromSubmit, shouldSkipSubmitValidation, validateSubmitForm } from './submitValues';
|
|
14
14
|
|
|
15
|
+
function getResponseRecord(response: unknown) {
|
|
16
|
+
if (!response || typeof response !== 'object') {
|
|
17
|
+
return response;
|
|
18
|
+
}
|
|
19
|
+
if ('data' in response) {
|
|
20
|
+
return (response as { data?: unknown }).data;
|
|
21
|
+
}
|
|
22
|
+
return response;
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
export async function submitHandler(ctx, params, cb?: (values?: any, filterByTk?: any) => void) {
|
|
16
26
|
const resource = ctx.resource;
|
|
17
27
|
const blockModel = ctx.blockModel as FormBlockModel;
|
|
@@ -41,30 +51,34 @@ export async function submitHandler(ctx, params, cb?: (values?: any, filterByTk?
|
|
|
41
51
|
resource.setFilterByTk(currentFilterByTk);
|
|
42
52
|
}
|
|
43
53
|
}
|
|
44
|
-
const data:
|
|
54
|
+
const data: unknown = cb ? await cb(values) : await resource.save(values, params.requestConfig);
|
|
55
|
+
let responseRecord = getResponseRecord(data);
|
|
45
56
|
if (isEditFormModel) {
|
|
46
57
|
resource.isNewRecord = false;
|
|
47
58
|
// 编辑表单保存成功后,表单应回到“已同步”状态:下一次刷新应允许覆盖为服务端值
|
|
48
59
|
blockModel.resetUserModifiedFields?.();
|
|
49
60
|
await resource.refresh();
|
|
61
|
+
responseRecord = responseRecord ?? resource.getData?.();
|
|
50
62
|
} else {
|
|
51
63
|
blockModel.form.resetFields();
|
|
52
64
|
blockModel.emitter.emit('onFieldReset');
|
|
53
65
|
blockModel.resetUserModifiedFields?.();
|
|
54
66
|
blockModel.formValueRuntime?.resetAfterFormReset?.();
|
|
55
67
|
if (ctx.view.inputArgs.collectionName === blockModel.collection.name && ctx.view.inputArgs.onChange) {
|
|
56
|
-
ctx.view.inputArgs.onChange(
|
|
68
|
+
ctx.view.inputArgs.onChange(responseRecord);
|
|
57
69
|
}
|
|
58
70
|
}
|
|
71
|
+
return responseRecord;
|
|
59
72
|
} else if (resource instanceof MultiRecordResource) {
|
|
60
73
|
const currentFilterByTk = resource.getMeta('currentFilterByTk');
|
|
61
74
|
if (!currentFilterByTk) {
|
|
62
75
|
ctx.message.error(ctx.t('No filterByTk found for multi-record resource.'));
|
|
63
76
|
return;
|
|
64
77
|
}
|
|
65
|
-
|
|
78
|
+
const data = cb
|
|
66
79
|
? await cb(values, currentFilterByTk)
|
|
67
80
|
: await resource.update(currentFilterByTk, values, params.requestConfig);
|
|
68
81
|
blockModel.resetUserModifiedFields?.();
|
|
82
|
+
return getResponseRecord(data) ?? blockModel.getCurrentRecord?.();
|
|
69
83
|
}
|
|
70
84
|
}
|
|
@@ -127,6 +127,93 @@ function createFieldContext(runtime: FormValueRuntime) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
describe('FormValueRuntime (default rules)', () => {
|
|
130
|
+
it('builds draft snapshots from current user-edited values only', async () => {
|
|
131
|
+
const engineEmitter = new EventEmitter();
|
|
132
|
+
const blockEmitter = new EventEmitter();
|
|
133
|
+
const formStub = createFormStub({});
|
|
134
|
+
const dispatchEvent = vi.fn();
|
|
135
|
+
|
|
136
|
+
const blockModel = {
|
|
137
|
+
uid: 'form-user-edited-draft-snapshot',
|
|
138
|
+
flowEngine: { emitter: engineEmitter },
|
|
139
|
+
emitter: blockEmitter,
|
|
140
|
+
dispatchEvent,
|
|
141
|
+
getAclActionName: () => 'create',
|
|
142
|
+
} as ConstructorParameters<typeof FormValueRuntime>[0]['model'];
|
|
143
|
+
|
|
144
|
+
const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as unknown as FormInstance });
|
|
145
|
+
runtime.mount({ sync: true });
|
|
146
|
+
|
|
147
|
+
const blockCtx = createFieldContext(runtime);
|
|
148
|
+
await runtime.setFormValues(blockCtx, [{ path: ['fixed'], value: 'Fixed auto value' }], { source: 'system' });
|
|
149
|
+
await runtime.setFormValues(blockCtx, [{ path: ['defaultTitle'], value: 'Default auto value' }], {
|
|
150
|
+
source: 'default',
|
|
151
|
+
});
|
|
152
|
+
await runtime.setFormValues(blockCtx, [{ path: ['overrideTitle'], value: 'Override auto value' }], {
|
|
153
|
+
source: 'override',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(runtime.getUserEditedValuePatches()).toEqual([]);
|
|
157
|
+
expect(runtime.getUserEditedValuesSnapshot()).toEqual({});
|
|
158
|
+
|
|
159
|
+
await runtime.setFormValues(
|
|
160
|
+
blockCtx,
|
|
161
|
+
[
|
|
162
|
+
{ path: ['title'], value: 'Manual title' },
|
|
163
|
+
{ path: ['roles', 0, 'roleName'], value: 'Manual role' },
|
|
164
|
+
],
|
|
165
|
+
{ source: 'user' },
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expect(runtime.getUserEditedValuePatches()).toEqual([
|
|
169
|
+
{ path: ['title'], value: 'Manual title' },
|
|
170
|
+
{ path: ['roles', 0, 'roleName'], value: 'Manual role' },
|
|
171
|
+
]);
|
|
172
|
+
expect(runtime.getUserEditedValuesSnapshot()).toEqual({
|
|
173
|
+
title: 'Manual title',
|
|
174
|
+
roles: [{ roleName: 'Manual role' }],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await runtime.setFormValues(blockCtx, [{ path: ['title'], value: 'Fixed title' }], { source: 'system' });
|
|
178
|
+
|
|
179
|
+
expect(runtime.getUserEditedValuesSnapshot()).toEqual({
|
|
180
|
+
roles: [{ roleName: 'Manual role' }],
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('omits non-user descendant values from parent user-edited draft patches', async () => {
|
|
185
|
+
const engineEmitter = new EventEmitter();
|
|
186
|
+
const blockEmitter = new EventEmitter();
|
|
187
|
+
const formStub = createFormStub({});
|
|
188
|
+
const dispatchEvent = vi.fn();
|
|
189
|
+
|
|
190
|
+
const blockModel = {
|
|
191
|
+
uid: 'form-user-edited-parent-draft-patch',
|
|
192
|
+
flowEngine: { emitter: engineEmitter },
|
|
193
|
+
emitter: blockEmitter,
|
|
194
|
+
dispatchEvent,
|
|
195
|
+
getAclActionName: () => 'create',
|
|
196
|
+
} as ConstructorParameters<typeof FormValueRuntime>[0]['model'];
|
|
197
|
+
|
|
198
|
+
const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as unknown as FormInstance });
|
|
199
|
+
runtime.mount({ sync: true });
|
|
200
|
+
|
|
201
|
+
const blockCtx = createFieldContext(runtime);
|
|
202
|
+
await runtime.setFormValues(
|
|
203
|
+
blockCtx,
|
|
204
|
+
[{ path: ['roles'], value: [{ roleName: 'Manual role', fixedRoleName: 'Initial fixed role' }] }],
|
|
205
|
+
{ source: 'user' },
|
|
206
|
+
);
|
|
207
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roles', 0, 'fixedRoleName'], value: 'System fixed role' }], {
|
|
208
|
+
source: 'system',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(runtime.getUserEditedValuePatches()).toEqual([{ path: ['roles'], value: [{ roleName: 'Manual role' }] }]);
|
|
212
|
+
expect(runtime.getUserEditedValuesSnapshot()).toEqual({
|
|
213
|
+
roles: [{ roleName: 'Manual role' }],
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
130
217
|
it('skips object patches when values are unchanged', async () => {
|
|
131
218
|
const engineEmitter = new EventEmitter();
|
|
132
219
|
const blockEmitter = new EventEmitter();
|