@nocobase/client-v2 2.1.0-alpha.31 → 2.1.0-alpha.32
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/JsonTextArea.d.ts +18 -0
- package/es/components/index.d.ts +1 -0
- package/es/flow/actions/dateRangeLimit.d.ts +9 -0
- package/es/flow/actions/index.d.ts +1 -0
- package/es/flow/models/base/PageModel/PageModel.d.ts +4 -0
- package/es/flow/models/base/PageModel/RootPageModel.d.ts +9 -0
- package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +7 -0
- package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +2 -0
- package/es/flow/models/fields/DateTimeFieldModel/dateLimit.d.ts +20 -0
- package/es/flow/models/fields/JSEditableFieldModel.d.ts +4 -0
- package/es/index.mjs +72 -65
- package/lib/index.js +61 -54
- package/package.json +6 -5
- package/src/components/form/JsonTextArea.tsx +129 -0
- package/src/components/index.ts +1 -0
- package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +478 -0
- package/src/flow/actions/__tests__/pattern.test.ts +190 -0
- package/src/flow/actions/dateRangeLimit.tsx +66 -0
- package/src/flow/actions/index.ts +1 -0
- package/src/flow/actions/linkageRules.tsx +117 -19
- package/src/flow/actions/openView.tsx +2 -1
- package/src/flow/actions/pattern.tsx +25 -2
- package/src/flow/admin-shell/AdminLayoutRouteCoordinator.ts +7 -1
- package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +117 -0
- package/src/flow/models/base/PageModel/PageModel.tsx +15 -3
- package/src/flow/models/base/PageModel/RootPageModel.tsx +37 -2
- package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +73 -0
- package/src/flow/models/base/PageModel/__tests__/RootPageModel.test.ts +116 -0
- package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +167 -1
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +103 -11
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +27 -3
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +47 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +42 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.refresh.test.tsx +122 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +2 -0
- package/src/flow/models/fields/ClickableFieldModel.tsx +21 -9
- package/src/flow/models/fields/DateTimeFieldModel/DateOnlyFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeFieldModel.tsx +4 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeNoTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/DateTimeTzFieldModel.tsx +9 -0
- package/src/flow/models/fields/DateTimeFieldModel/__tests__/DateTimeNoTzFieldModel.dateLimit.test.tsx +242 -0
- package/src/flow/models/fields/DateTimeFieldModel/dateLimit.ts +152 -0
- package/src/flow/models/fields/JSEditableFieldModel.tsx +110 -14
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +87 -0
- package/src/flow/models/fields/__tests__/JSEditableFieldModel.test.tsx +210 -0
|
@@ -0,0 +1,117 @@
|
|
|
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 } from '@nocobase/flow-engine';
|
|
11
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
12
|
+
import { getViewDiffAndUpdateHidden } from '../../getViewDiffAndUpdateHidden';
|
|
13
|
+
import { getOpenViewStepParams } from '../../flows/openViewFlow';
|
|
14
|
+
import { resolveViewParamsToViewList } from '../../resolveViewParamsToViewList';
|
|
15
|
+
import { AdminLayoutRouteCoordinator } from '../AdminLayoutRouteCoordinator';
|
|
16
|
+
import { RouteModel } from '../../models/base/RouteModel';
|
|
17
|
+
|
|
18
|
+
vi.mock('../../resolveViewParamsToViewList', () => ({
|
|
19
|
+
resolveViewParamsToViewList: vi.fn(),
|
|
20
|
+
updateViewListHidden: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('../../getViewDiffAndUpdateHidden', () => ({
|
|
24
|
+
getViewDiffAndUpdateHidden: vi.fn(),
|
|
25
|
+
getKey: vi.fn((viewItem) => viewItem.params.viewUid),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../../flows/openViewFlow', async (importOriginal) => {
|
|
29
|
+
const actual = await importOriginal();
|
|
30
|
+
return {
|
|
31
|
+
...(actual as any),
|
|
32
|
+
getOpenViewStepParams: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const mockResolveViewParamsToViewList = vi.mocked(resolveViewParamsToViewList);
|
|
37
|
+
const mockGetViewDiffAndUpdateHidden = vi.mocked(getViewDiffAndUpdateHidden);
|
|
38
|
+
const mockGetOpenViewStepParams = vi.mocked(getOpenViewStepParams);
|
|
39
|
+
|
|
40
|
+
function setupRouteReplay(viewParams: Record<string, any>) {
|
|
41
|
+
const engine = new FlowEngine();
|
|
42
|
+
engine.registerModels({ RouteModel });
|
|
43
|
+
engine.context.defineProperty('route', {
|
|
44
|
+
value: {
|
|
45
|
+
params: { name: 'test-route' },
|
|
46
|
+
pathname: '/admin/popup/filterbytk/member',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
engine.context.defineProperty('routeRepository', {
|
|
50
|
+
value: {
|
|
51
|
+
getRouteBySchemaUid: vi.fn(() => ({})),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const dispatchEvent = vi.fn(() => Promise.resolve());
|
|
56
|
+
const viewItem = {
|
|
57
|
+
params: {
|
|
58
|
+
viewUid: 'popup',
|
|
59
|
+
filterByTk: 'member',
|
|
60
|
+
...viewParams,
|
|
61
|
+
},
|
|
62
|
+
modelUid: 'popup',
|
|
63
|
+
model: { uid: 'popup', dispatchEvent } as any,
|
|
64
|
+
hidden: { value: false },
|
|
65
|
+
index: 0,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
mockResolveViewParamsToViewList.mockReturnValue([viewItem]);
|
|
69
|
+
mockGetViewDiffAndUpdateHidden.mockReturnValue({
|
|
70
|
+
viewsToClose: [],
|
|
71
|
+
viewsToOpen: [viewItem],
|
|
72
|
+
});
|
|
73
|
+
mockGetOpenViewStepParams.mockReturnValue({
|
|
74
|
+
collectionName: 'roles',
|
|
75
|
+
associationName: 'users.roles',
|
|
76
|
+
dataSourceKey: 'main',
|
|
77
|
+
} as any);
|
|
78
|
+
|
|
79
|
+
const coordinator = new AdminLayoutRouteCoordinator(engine);
|
|
80
|
+
coordinator.registerPage('test-route', {
|
|
81
|
+
active: true,
|
|
82
|
+
layoutContentElement: document.createElement('div'),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return { dispatchEvent };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('AdminLayoutRouteCoordinator', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('drops configured associationName during route replay when sourceId is absent', () => {
|
|
94
|
+
const { dispatchEvent } = setupRouteReplay({});
|
|
95
|
+
|
|
96
|
+
expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
|
|
97
|
+
collectionName: 'roles',
|
|
98
|
+
associationName: null,
|
|
99
|
+
dataSourceKey: 'main',
|
|
100
|
+
filterByTk: 'member',
|
|
101
|
+
triggerByRouter: true,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('keeps configured associationName during route replay when sourceId is present', () => {
|
|
106
|
+
const { dispatchEvent } = setupRouteReplay({ sourceId: '1' });
|
|
107
|
+
|
|
108
|
+
expect(dispatchEvent.mock.calls[0][1]).toMatchObject({
|
|
109
|
+
collectionName: 'roles',
|
|
110
|
+
associationName: 'users.roles',
|
|
111
|
+
dataSourceKey: 'main',
|
|
112
|
+
filterByTk: 'member',
|
|
113
|
+
sourceId: '1',
|
|
114
|
+
triggerByRouter: true,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -49,6 +49,17 @@ export class PageModel extends FlowModel<PageModelStructure> {
|
|
|
49
49
|
private unmounted = false;
|
|
50
50
|
private documentTitleUpdateVersion = 0;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 根页面标签页开关以路由表为准,避免 flow model 里的旧配置覆盖路由管理设置。
|
|
54
|
+
*/
|
|
55
|
+
private getEnableTabs(): boolean {
|
|
56
|
+
const routeEnableTabs = (this.context as any)?.currentRoute?.enableTabs;
|
|
57
|
+
if (this.props.routeId != null && typeof routeEnableTabs === 'boolean') {
|
|
58
|
+
return routeEnableTabs;
|
|
59
|
+
}
|
|
60
|
+
return !!this.props.enableTabs;
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
private getActiveTabKey(): string | undefined {
|
|
53
64
|
const viewParams = this.context.view?.navigation?.viewParams;
|
|
54
65
|
if (viewParams) {
|
|
@@ -193,7 +204,7 @@ export class PageModel extends FlowModel<PageModelStructure> {
|
|
|
193
204
|
};
|
|
194
205
|
|
|
195
206
|
let nextTitle = '';
|
|
196
|
-
if (this.
|
|
207
|
+
if (this.getEnableTabs()) {
|
|
197
208
|
const activeTabKey = preferredActiveTabKey || this.getActiveTabKey();
|
|
198
209
|
const activeTabModel = activeTabKey
|
|
199
210
|
? (this.flowEngine.getModel(activeTabKey) as BasePageTabModel | undefined)
|
|
@@ -356,13 +367,14 @@ export class PageModel extends FlowModel<PageModelStructure> {
|
|
|
356
367
|
headerStyle.paddingBlock = token.paddingSM;
|
|
357
368
|
headerStyle.paddingInline = token.paddingLG;
|
|
358
369
|
}
|
|
359
|
-
|
|
370
|
+
const enableTabs = this.getEnableTabs();
|
|
371
|
+
if (enableTabs) {
|
|
360
372
|
headerStyle.paddingBottom = 0;
|
|
361
373
|
}
|
|
362
374
|
return (
|
|
363
375
|
<>
|
|
364
376
|
{this.props.displayTitle && <PageHeader title={this.props.title} style={headerStyle} />}
|
|
365
|
-
{
|
|
377
|
+
{enableTabs ? this.renderTabs() : this.renderFirstTab()}
|
|
366
378
|
</>
|
|
367
379
|
);
|
|
368
380
|
}
|
|
@@ -17,6 +17,31 @@ import { PageModel } from './PageModel';
|
|
|
17
17
|
export class RootPageModel extends PageModel {
|
|
18
18
|
mounted = false;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 打开页面设置前,把标签页开关表单值同步为路由表中的当前状态。
|
|
22
|
+
*/
|
|
23
|
+
private syncPageSettingsEnableTabsFromRoute() {
|
|
24
|
+
const routeEnableTabs = (this.context as any)?.currentRoute?.enableTabs;
|
|
25
|
+
if (typeof routeEnableTabs !== 'boolean') {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
this.setStepParams('pageSettings', 'general', {
|
|
29
|
+
enableTabs: routeEnableTabs,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 保存页面设置后立即同步当前页面状态,让标签页显隐无需等路由列表刷新或页面重载。
|
|
35
|
+
*/
|
|
36
|
+
private syncEnableTabsToCurrentPage(enableTabs: boolean) {
|
|
37
|
+
const currentRoute = (this.context as any)?.currentRoute;
|
|
38
|
+
const routeId = this.props.routeId;
|
|
39
|
+
if (currentRoute && (routeId == null || currentRoute.id == null || String(currentRoute.id) === String(routeId))) {
|
|
40
|
+
currentRoute.enableTabs = enableTabs;
|
|
41
|
+
}
|
|
42
|
+
this.setProps('enableTabs', enableTabs);
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
/**
|
|
21
46
|
* 新建 tab 在首次保存完成前,前端 route 里可能还没有数据库 id。
|
|
22
47
|
* 拖拽前兜底触发一次保存,确保 move 接口拿到真实主键。
|
|
@@ -65,18 +90,28 @@ export class RootPageModel extends PageModel {
|
|
|
65
90
|
);
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
async openFlowSettings(options?: Parameters<PageModel['openFlowSettings']>[0]) {
|
|
94
|
+
if (options?.flowKey === 'pageSettings' && options?.stepKey === 'general') {
|
|
95
|
+
this.syncPageSettingsEnableTabsFromRoute();
|
|
96
|
+
}
|
|
97
|
+
return super.openFlowSettings(options);
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
async saveStepParams() {
|
|
69
101
|
await super.saveStepParams();
|
|
70
102
|
|
|
71
103
|
if (this.stepParams.pageSettings) {
|
|
104
|
+
const enableTabs = !!this.stepParams.pageSettings.general.enableTabs;
|
|
72
105
|
// 更新路由
|
|
73
|
-
this.context.api.request({
|
|
106
|
+
await this.context.api.request({
|
|
74
107
|
url: `desktopRoutes:update?filter[id]=${this.props.routeId}`,
|
|
75
108
|
method: 'post',
|
|
76
109
|
data: {
|
|
77
|
-
enableTabs
|
|
110
|
+
enableTabs,
|
|
78
111
|
},
|
|
79
112
|
});
|
|
113
|
+
this.syncEnableTabsToCurrentPage(enableTabs);
|
|
114
|
+
await this.context.refreshDesktopRoutes?.();
|
|
80
115
|
}
|
|
81
116
|
}
|
|
82
117
|
|
|
@@ -412,6 +412,7 @@ describe('PageModel', () => {
|
|
|
412
412
|
describe('render header spacing with tabs', () => {
|
|
413
413
|
it('should compact page header bottom spacing when tabs are enabled', () => {
|
|
414
414
|
pageModel.props = {
|
|
415
|
+
routeId: 'route-1',
|
|
415
416
|
displayTitle: true,
|
|
416
417
|
enableTabs: true,
|
|
417
418
|
title: 'Title',
|
|
@@ -430,6 +431,7 @@ describe('PageModel', () => {
|
|
|
430
431
|
|
|
431
432
|
it('should keep original header style when tabs are disabled', () => {
|
|
432
433
|
pageModel.props = {
|
|
434
|
+
routeId: 'route-1',
|
|
433
435
|
displayTitle: true,
|
|
434
436
|
enableTabs: false,
|
|
435
437
|
title: 'Title',
|
|
@@ -442,6 +444,57 @@ describe('PageModel', () => {
|
|
|
442
444
|
|
|
443
445
|
expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
|
|
444
446
|
});
|
|
447
|
+
|
|
448
|
+
it('should use desktop route enableTabs=false before flow model props', () => {
|
|
449
|
+
pageModel.props = {
|
|
450
|
+
routeId: 'route-1',
|
|
451
|
+
displayTitle: true,
|
|
452
|
+
enableTabs: true,
|
|
453
|
+
title: 'Title',
|
|
454
|
+
headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
|
|
455
|
+
} as any;
|
|
456
|
+
(pageModel as any).context = {
|
|
457
|
+
currentRoute: {
|
|
458
|
+
enableTabs: false,
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
pageModel.renderTabs = vi.fn(() => null);
|
|
462
|
+
pageModel.renderFirstTab = vi.fn(() => null);
|
|
463
|
+
|
|
464
|
+
const result = pageModel.render() as any;
|
|
465
|
+
const header = result.props.children[0];
|
|
466
|
+
|
|
467
|
+
expect(pageModel.renderTabs).not.toHaveBeenCalled();
|
|
468
|
+
expect(pageModel.renderFirstTab).toHaveBeenCalled();
|
|
469
|
+
expect(header.props.style).toEqual({ backgroundColor: 'var(--colorBgLayout)' });
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should use desktop route enableTabs=true before flow model props', () => {
|
|
473
|
+
pageModel.props = {
|
|
474
|
+
routeId: 'route-1',
|
|
475
|
+
displayTitle: true,
|
|
476
|
+
enableTabs: false,
|
|
477
|
+
title: 'Title',
|
|
478
|
+
headerStyle: { backgroundColor: 'var(--colorBgLayout)' },
|
|
479
|
+
} as any;
|
|
480
|
+
(pageModel as any).context = {
|
|
481
|
+
currentRoute: {
|
|
482
|
+
enableTabs: true,
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
pageModel.renderTabs = vi.fn(() => null);
|
|
486
|
+
pageModel.renderFirstTab = vi.fn(() => null);
|
|
487
|
+
|
|
488
|
+
const result = pageModel.render() as any;
|
|
489
|
+
const header = result.props.children[0];
|
|
490
|
+
|
|
491
|
+
expect(pageModel.renderTabs).toHaveBeenCalled();
|
|
492
|
+
expect(pageModel.renderFirstTab).not.toHaveBeenCalled();
|
|
493
|
+
expect(header.props.style).toMatchObject({
|
|
494
|
+
backgroundColor: 'var(--colorBgLayout)',
|
|
495
|
+
paddingBottom: 0,
|
|
496
|
+
});
|
|
497
|
+
});
|
|
445
498
|
});
|
|
446
499
|
|
|
447
500
|
describe('dirty refresh signal', () => {
|
|
@@ -574,6 +627,26 @@ describe('PageModel', () => {
|
|
|
574
627
|
expect(document.title).toBe('Resolved tab doc title');
|
|
575
628
|
});
|
|
576
629
|
|
|
630
|
+
it('should use page documentTitle when desktop route disables tabs even if flow model enables tabs', async () => {
|
|
631
|
+
pageModel.props = { routeId: 'route-1', enableTabs: true, title: 'Route page title' } as any;
|
|
632
|
+
(pageModel as any).context.currentRoute = {
|
|
633
|
+
enableTabs: false,
|
|
634
|
+
};
|
|
635
|
+
(pageModel as any).stepParams = {
|
|
636
|
+
pageSettings: {
|
|
637
|
+
general: {
|
|
638
|
+
documentTitle: 'Route page doc title',
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
(pageModel as any).context.resolveJsonTemplate = vi.fn(async () => 'Resolved route page doc title');
|
|
643
|
+
|
|
644
|
+
await (pageModel as any).updateDocumentTitle();
|
|
645
|
+
|
|
646
|
+
expect((pageModel as any).context.resolveJsonTemplate).toHaveBeenCalledWith('Route page doc title');
|
|
647
|
+
expect(document.title).toBe('Resolved route page doc title');
|
|
648
|
+
});
|
|
649
|
+
|
|
577
650
|
it('should fallback to tab title when active tab documentTitle is empty', async () => {
|
|
578
651
|
pageModel.props = { enableTabs: true } as any;
|
|
579
652
|
const activeTab = {
|
|
@@ -12,10 +12,32 @@ import { RootPageModel } from '../RootPageModel';
|
|
|
12
12
|
|
|
13
13
|
// Mock PageModel
|
|
14
14
|
const mockPageModelSaveStepParams = vi.fn();
|
|
15
|
+
const mockPageModelOpenFlowSettings = vi.fn();
|
|
15
16
|
vi.mock('../PageModel', () => ({
|
|
16
17
|
PageModel: class {
|
|
18
|
+
props: any = {};
|
|
19
|
+
stepParams: any = {};
|
|
20
|
+
|
|
17
21
|
static registerFlow() {}
|
|
18
22
|
|
|
23
|
+
setProps(key: string, value: any) {
|
|
24
|
+
this.props[key] = value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setStepParams(flowKey: string, stepKey: string, params: Record<string, any>) {
|
|
28
|
+
if (!this.stepParams[flowKey]) {
|
|
29
|
+
this.stepParams[flowKey] = {};
|
|
30
|
+
}
|
|
31
|
+
this.stepParams[flowKey][stepKey] = {
|
|
32
|
+
...this.stepParams[flowKey][stepKey],
|
|
33
|
+
...params,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async openFlowSettings(options?: any) {
|
|
38
|
+
return mockPageModelOpenFlowSettings(options);
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
async saveStepParams() {
|
|
20
42
|
return mockPageModelSaveStepParams();
|
|
21
43
|
}
|
|
@@ -26,6 +48,7 @@ describe('RootPageModel', () => {
|
|
|
26
48
|
let rootPageModel: RootPageModel;
|
|
27
49
|
let mockContext: any;
|
|
28
50
|
let mockApi: any;
|
|
51
|
+
let mockRefreshDesktopRoutes: any;
|
|
29
52
|
let mockFlowEngine: any;
|
|
30
53
|
|
|
31
54
|
beforeEach(() => {
|
|
@@ -35,6 +58,7 @@ describe('RootPageModel', () => {
|
|
|
35
58
|
mockApi = {
|
|
36
59
|
request: vi.fn().mockResolvedValue({ data: { success: true } }),
|
|
37
60
|
};
|
|
61
|
+
mockRefreshDesktopRoutes = vi.fn().mockResolvedValue(undefined);
|
|
38
62
|
|
|
39
63
|
// Mock FlowEngine
|
|
40
64
|
mockFlowEngine = {
|
|
@@ -45,6 +69,11 @@ describe('RootPageModel', () => {
|
|
|
45
69
|
// Mock context
|
|
46
70
|
mockContext = {
|
|
47
71
|
api: mockApi,
|
|
72
|
+
refreshDesktopRoutes: mockRefreshDesktopRoutes,
|
|
73
|
+
currentRoute: {
|
|
74
|
+
id: 'route-123',
|
|
75
|
+
enableTabs: true,
|
|
76
|
+
},
|
|
48
77
|
};
|
|
49
78
|
|
|
50
79
|
// Create RootPageModel instance
|
|
@@ -63,6 +92,65 @@ describe('RootPageModel', () => {
|
|
|
63
92
|
};
|
|
64
93
|
});
|
|
65
94
|
|
|
95
|
+
describe('openFlowSettings', () => {
|
|
96
|
+
it('should use desktop route enableTabs as settings dialog initial value', async () => {
|
|
97
|
+
mockContext.currentRoute.enableTabs = false;
|
|
98
|
+
(rootPageModel as any).stepParams = {
|
|
99
|
+
pageSettings: {
|
|
100
|
+
general: {
|
|
101
|
+
displayTitle: true,
|
|
102
|
+
enableTabs: true,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
await rootPageModel.openFlowSettings({ flowKey: 'pageSettings', stepKey: 'general' } as any);
|
|
108
|
+
|
|
109
|
+
expect((rootPageModel as any).stepParams.pageSettings.general).toMatchObject({
|
|
110
|
+
displayTitle: true,
|
|
111
|
+
enableTabs: false,
|
|
112
|
+
});
|
|
113
|
+
expect(mockPageModelOpenFlowSettings).toHaveBeenCalledWith({
|
|
114
|
+
flowKey: 'pageSettings',
|
|
115
|
+
stepKey: 'general',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should keep flow model enableTabs when route status is unavailable', async () => {
|
|
120
|
+
mockContext.currentRoute = {};
|
|
121
|
+
(rootPageModel as any).stepParams = {
|
|
122
|
+
pageSettings: {
|
|
123
|
+
general: {
|
|
124
|
+
enableTabs: true,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
await rootPageModel.openFlowSettings({ flowKey: 'pageSettings', stepKey: 'general' } as any);
|
|
130
|
+
|
|
131
|
+
expect((rootPageModel as any).stepParams.pageSettings.general.enableTabs).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should not sync enableTabs when opening other settings steps', async () => {
|
|
135
|
+
mockContext.currentRoute.enableTabs = false;
|
|
136
|
+
(rootPageModel as any).stepParams = {
|
|
137
|
+
pageSettings: {
|
|
138
|
+
general: {
|
|
139
|
+
enableTabs: true,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
await rootPageModel.openFlowSettings({ flowKey: 'otherSettings', stepKey: 'general' } as any);
|
|
145
|
+
|
|
146
|
+
expect((rootPageModel as any).stepParams.pageSettings.general.enableTabs).toBe(true);
|
|
147
|
+
expect(mockPageModelOpenFlowSettings).toHaveBeenCalledWith({
|
|
148
|
+
flowKey: 'otherSettings',
|
|
149
|
+
stepKey: 'general',
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
66
154
|
describe('saveStepParams', () => {
|
|
67
155
|
it('should call parent saveStepParams method', async () => {
|
|
68
156
|
await rootPageModel.saveStepParams();
|
|
@@ -89,6 +177,34 @@ describe('RootPageModel', () => {
|
|
|
89
177
|
},
|
|
90
178
|
});
|
|
91
179
|
});
|
|
180
|
+
|
|
181
|
+
it('should refresh desktop routes after route update is persisted', async () => {
|
|
182
|
+
await rootPageModel.saveStepParams();
|
|
183
|
+
|
|
184
|
+
expect(mockApi.request).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect(mockRefreshDesktopRoutes).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(mockApi.request.mock.invocationCallOrder[0]).toBeLessThan(
|
|
187
|
+
mockRefreshDesktopRoutes.mock.invocationCallOrder[0],
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should apply enableTabs to current page immediately after route update is persisted', async () => {
|
|
192
|
+
(rootPageModel as any).stepParams = {
|
|
193
|
+
pageSettings: {
|
|
194
|
+
general: {
|
|
195
|
+
enableTabs: false,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
await rootPageModel.saveStepParams();
|
|
201
|
+
|
|
202
|
+
expect(mockContext.currentRoute.enableTabs).toBe(false);
|
|
203
|
+
expect((rootPageModel as any).props.enableTabs).toBe(false);
|
|
204
|
+
expect(mockApi.request.mock.invocationCallOrder[0]).toBeLessThan(
|
|
205
|
+
mockRefreshDesktopRoutes.mock.invocationCallOrder[0],
|
|
206
|
+
);
|
|
207
|
+
});
|
|
92
208
|
});
|
|
93
209
|
|
|
94
210
|
describe('handleDragEnd', () => {
|
|
@@ -159,12 +159,141 @@ describe('FormValueRuntime (default rules)', () => {
|
|
|
159
159
|
await runtime.setFormValues(fieldCtx, [{ path: ['b'], value: 'Y' }], { source: 'user' });
|
|
160
160
|
await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('Y'));
|
|
161
161
|
|
|
162
|
+
// change dependency again -> default keeps following while target is still the last default
|
|
163
|
+
await runtime.setFormValues(fieldCtx, [{ path: ['b'], value: 'Z' }], { source: 'user' });
|
|
164
|
+
await waitFor(() => expect(formStub.getFieldValue(['a'])).toBe('Z'));
|
|
165
|
+
|
|
162
166
|
// user changes target -> default should be disabled permanently
|
|
163
167
|
await runtime.setFormValues(fieldCtx, [{ path: ['a'], value: 'user' }], { source: 'user' });
|
|
164
|
-
await runtime.setFormValues(fieldCtx, [{ path: ['b'], value: '
|
|
168
|
+
await runtime.setFormValues(fieldCtx, [{ path: ['b'], value: 'W' }], { source: 'user' });
|
|
165
169
|
expect(formStub.getFieldValue(['a'])).toBe('user');
|
|
166
170
|
});
|
|
167
171
|
|
|
172
|
+
it('allows direct default patches to keep following until target is explicitly changed', async () => {
|
|
173
|
+
const engineEmitter = new EventEmitter();
|
|
174
|
+
const blockEmitter = new EventEmitter();
|
|
175
|
+
const formStub = createFormStub({ roleUid: 'role-uid-1', roleName: '' });
|
|
176
|
+
|
|
177
|
+
const blockModel: any = {
|
|
178
|
+
uid: 'form-direct-default-patch',
|
|
179
|
+
flowEngine: { emitter: engineEmitter },
|
|
180
|
+
emitter: blockEmitter,
|
|
181
|
+
dispatchEvent: vi.fn(),
|
|
182
|
+
getAclActionName: () => 'create',
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
|
|
186
|
+
runtime.mount({ sync: true });
|
|
187
|
+
|
|
188
|
+
const blockCtx = createFieldContext(runtime);
|
|
189
|
+
blockModel.context = blockCtx;
|
|
190
|
+
|
|
191
|
+
expect(runtime.canApplyDefaultValuePatch(['roleName'], 'role-uid-1')).toBe(true);
|
|
192
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roleName'], value: 'role-uid-1' }], {
|
|
193
|
+
source: 'linkage',
|
|
194
|
+
txId: 'tx-linkage-1',
|
|
195
|
+
linkageTxId: 'tx-linkage-1',
|
|
196
|
+
});
|
|
197
|
+
runtime.recordDefaultValuePatch(['roleName'], 'role-uid-1');
|
|
198
|
+
|
|
199
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roleUid'], value: 'role-uid-2' }], { source: 'user' });
|
|
200
|
+
expect(runtime.canApplyDefaultValuePatch(['roleName'], 'role-uid-2')).toBe(true);
|
|
201
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roleName'], value: 'role-uid-2' }], {
|
|
202
|
+
source: 'linkage',
|
|
203
|
+
txId: 'tx-linkage-2',
|
|
204
|
+
linkageTxId: 'tx-linkage-2',
|
|
205
|
+
});
|
|
206
|
+
runtime.recordDefaultValuePatch(['roleName'], 'role-uid-2');
|
|
207
|
+
|
|
208
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roleUid'], value: 'role-uid-3' }], { source: 'user' });
|
|
209
|
+
expect(runtime.canApplyDefaultValuePatch(['roleName'], 'role-uid-3')).toBe(true);
|
|
210
|
+
|
|
211
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roleName'], value: 'manual' }], { source: 'user' });
|
|
212
|
+
expect(runtime.canApplyDefaultValuePatch(['roleName'], 'role-uid-4')).toBe(false);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('keeps direct default patches active when an unchanged default value is carried by a row change', async () => {
|
|
216
|
+
const engineEmitter = new EventEmitter();
|
|
217
|
+
const blockEmitter = new EventEmitter();
|
|
218
|
+
const formStub = createFormStub({ roles: [{ uid: '1', title: '' }] });
|
|
219
|
+
|
|
220
|
+
const blockModel: any = {
|
|
221
|
+
uid: 'form-direct-default-patch-row',
|
|
222
|
+
flowEngine: { emitter: engineEmitter },
|
|
223
|
+
emitter: blockEmitter,
|
|
224
|
+
dispatchEvent: vi.fn(),
|
|
225
|
+
getAclActionName: () => 'create',
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
|
|
229
|
+
runtime.mount({ sync: true });
|
|
230
|
+
|
|
231
|
+
const blockCtx = createFieldContext(runtime);
|
|
232
|
+
blockModel.context = blockCtx;
|
|
233
|
+
|
|
234
|
+
expect(runtime.canApplyDefaultValuePatch(['roles', 0, 'title'], '1')).toBe(true);
|
|
235
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roles', 0, 'title'], value: '1' }], {
|
|
236
|
+
source: 'linkage',
|
|
237
|
+
txId: 'tx-linkage-row-1',
|
|
238
|
+
linkageTxId: 'tx-linkage-row-1',
|
|
239
|
+
});
|
|
240
|
+
runtime.recordDefaultValuePatch(['roles', 0, 'title'], '1');
|
|
241
|
+
|
|
242
|
+
lodashSet((runtime as any).valuesMirror, ['roles', 0, 'title'], undefined);
|
|
243
|
+
runtime.handleFormFieldsChange([{ name: ['roles', 0, 'title'], touched: true } as any]);
|
|
244
|
+
expect((runtime as any).findExplicitHit('roles[0].title')).toBeNull();
|
|
245
|
+
expect(runtime.canApplyDefaultValuePatch(['roles', 0, 'title'], '12')).toBe(true);
|
|
246
|
+
|
|
247
|
+
(runtime as any).markExplicit('roles');
|
|
248
|
+
expect((runtime as any).findExplicitHit('roles[0].title')).toBeNull();
|
|
249
|
+
expect(runtime.canApplyDefaultValuePatch(['roles', 0, 'title'], '12')).toBe(true);
|
|
250
|
+
|
|
251
|
+
lodashSet((runtime as any).valuesMirror, ['roles', 0, 'title'], undefined);
|
|
252
|
+
|
|
253
|
+
lodashSet((formStub as any).__store, ['roles', 0, 'uid'], '12');
|
|
254
|
+
runtime.handleFormValuesChange({ roles: formStub.getFieldValue(['roles']) }, formStub.getFieldsValue());
|
|
255
|
+
|
|
256
|
+
expect((runtime as any).findExplicitHit('roles[0].title')).toBeNull();
|
|
257
|
+
expect(runtime.canApplyDefaultValuePatch(['roles', 0, 'title'], '12')).toBe(true);
|
|
258
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roles', 0, 'title'], value: '12' }], {
|
|
259
|
+
source: 'linkage',
|
|
260
|
+
txId: 'tx-linkage-row-2',
|
|
261
|
+
linkageTxId: 'tx-linkage-row-2',
|
|
262
|
+
});
|
|
263
|
+
runtime.recordDefaultValuePatch(['roles', 0, 'title'], '12');
|
|
264
|
+
expect(formStub.getFieldValue(['roles', 0, 'title'])).toBe('12');
|
|
265
|
+
|
|
266
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roles', 0, 'title'], value: 'manual' }], { source: 'user' });
|
|
267
|
+
expect(runtime.canApplyDefaultValuePatch(['roles', 0, 'title'], '13')).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('does not mark omitted sibling fields from partial top-level changedValues as explicit', async () => {
|
|
271
|
+
const engineEmitter = new EventEmitter();
|
|
272
|
+
const blockEmitter = new EventEmitter();
|
|
273
|
+
const formStub = createFormStub({
|
|
274
|
+
roles: [{ __is_new__: true, __index__: 'row-1', name: '1', title: '1' }],
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const blockModel: any = {
|
|
278
|
+
uid: 'form-direct-default-patch-partial-row',
|
|
279
|
+
flowEngine: { emitter: engineEmitter },
|
|
280
|
+
emitter: blockEmitter,
|
|
281
|
+
dispatchEvent: vi.fn(),
|
|
282
|
+
getAclActionName: () => 'create',
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
|
|
286
|
+
runtime.mount({ sync: true });
|
|
287
|
+
|
|
288
|
+
runtime.recordDefaultValuePatch(['roles', 0, 'title'], '1');
|
|
289
|
+
|
|
290
|
+
lodashSet((formStub as any).__store, ['roles', 0, 'name'], '12');
|
|
291
|
+
runtime.handleFormValuesChange({ roles: [{ name: '12' }] }, formStub.getFieldsValue());
|
|
292
|
+
|
|
293
|
+
expect((runtime as any).findExplicitHit('roles[0].title')).toBeNull();
|
|
294
|
+
expect(runtime.canApplyDefaultValuePatch(['roles', 0, 'title'], '12')).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
168
297
|
it('handles onFieldsChange name as string and triggers default recompute', async () => {
|
|
169
298
|
const engineEmitter = new EventEmitter();
|
|
170
299
|
const blockEmitter = new EventEmitter();
|
|
@@ -3267,4 +3396,41 @@ describe('FormValueRuntime (form assign rules)', () => {
|
|
|
3267
3396
|
expect(payload.txId).toBe('tx-current');
|
|
3268
3397
|
expect(payload.linkageTxId).toBe('tx-root');
|
|
3269
3398
|
});
|
|
3399
|
+
|
|
3400
|
+
it('does not mark linkage writes explicit so later user edits can keep following default linkage', async () => {
|
|
3401
|
+
const engineEmitter = new EventEmitter();
|
|
3402
|
+
const blockEmitter = new EventEmitter();
|
|
3403
|
+
const formStub = createFormStub({ roleUid: 'role-uid-1', roleName: '' });
|
|
3404
|
+
|
|
3405
|
+
const blockModel: any = {
|
|
3406
|
+
uid: 'form-linkage-linkage-not-explicit',
|
|
3407
|
+
flowEngine: { emitter: engineEmitter },
|
|
3408
|
+
emitter: blockEmitter,
|
|
3409
|
+
dispatchEvent: vi.fn(),
|
|
3410
|
+
getAclActionName: () => 'create',
|
|
3411
|
+
};
|
|
3412
|
+
|
|
3413
|
+
const runtime = new FormValueRuntime({ model: blockModel, getForm: () => formStub as any });
|
|
3414
|
+
runtime.mount({ sync: true });
|
|
3415
|
+
|
|
3416
|
+
const blockCtx = createFieldContext(runtime);
|
|
3417
|
+
blockModel.context = blockCtx;
|
|
3418
|
+
|
|
3419
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roleName'], value: 'role-uid-1' }], {
|
|
3420
|
+
source: 'linkage',
|
|
3421
|
+
txId: 'tx-linkage-1',
|
|
3422
|
+
linkageTxId: 'tx-linkage-1',
|
|
3423
|
+
linkageScopeDepth: 0,
|
|
3424
|
+
});
|
|
3425
|
+
|
|
3426
|
+
expect(formStub.getFieldValue(['roleName'])).toBe('role-uid-1');
|
|
3427
|
+
expect((runtime as any).findExplicitHit('roleName')).toBeNull();
|
|
3428
|
+
|
|
3429
|
+
await runtime.setFormValues(blockCtx, [{ path: ['roleUid'], value: 'role-uid-2' }], {
|
|
3430
|
+
source: 'user',
|
|
3431
|
+
txId: 'tx-user-1',
|
|
3432
|
+
});
|
|
3433
|
+
expect(formStub.getFieldValue(['roleUid'])).toBe('role-uid-2');
|
|
3434
|
+
expect((runtime as any).findExplicitHit('roleName')).toBeNull();
|
|
3435
|
+
});
|
|
3270
3436
|
});
|