@nocobase/flow-engine 2.1.0-alpha.40 → 2.1.0-alpha.45
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/lib/FlowContextProvider.d.ts +5 -1
- package/lib/FlowContextProvider.js +9 -2
- package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +8 -1
- package/lib/components/subModel/LazyDropdown.js +200 -16
- package/lib/data-source/index.d.ts +9 -0
- package/lib/data-source/index.js +12 -0
- package/lib/flowContext.js +3 -0
- package/lib/flowEngine.js +3 -3
- package/lib/models/flowModel.js +3 -3
- package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
- package/lib/utils/parsePathnameToViewParams.js +28 -4
- package/lib/views/ViewNavigation.d.ts +12 -2
- package/lib/views/ViewNavigation.js +22 -7
- package/lib/views/createViewMeta.js +114 -50
- package/lib/views/inheritLayoutContext.d.ts +10 -0
- package/lib/views/inheritLayoutContext.js +50 -0
- package/lib/views/useDialog.js +2 -0
- package/lib/views/useDrawer.js +2 -0
- package/lib/views/usePage.js +2 -0
- package/package.json +4 -4
- package/src/FlowContextProvider.tsx +9 -1
- package/src/__tests__/createViewMeta.popup.test.ts +115 -1
- package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
- package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +11 -1
- package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +5 -2
- package/src/components/subModel/LazyDropdown.tsx +228 -16
- package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +203 -1
- package/src/data-source/index.ts +18 -0
- package/src/executor/__tests__/flowExecutor.test.ts +28 -0
- package/src/flowContext.ts +3 -0
- package/src/flowEngine.ts +4 -3
- package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
- package/src/models/__tests__/flowModel.test.ts +33 -34
- package/src/models/flowModel.tsx +3 -3
- package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
- package/src/utils/parsePathnameToViewParams.ts +45 -5
- package/src/views/ViewNavigation.ts +40 -7
- package/src/views/__tests__/ViewNavigation.test.ts +52 -0
- package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
- package/src/views/createViewMeta.ts +106 -34
- package/src/views/inheritLayoutContext.ts +26 -0
- package/src/views/useDialog.tsx +2 -0
- package/src/views/useDrawer.tsx +2 -0
- package/src/views/usePage.tsx +2 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
|
-
import { act, render, screen, userEvent, waitFor } from '@nocobase/test/client';
|
|
11
|
+
import { act, fireEvent, render, screen, userEvent, waitFor } from '@nocobase/test/client';
|
|
12
12
|
import { vi, beforeEach } from 'vitest';
|
|
13
13
|
import {
|
|
14
14
|
AddSubModelButton,
|
|
@@ -354,6 +354,208 @@ describe('transformItems - searchable flags', () => {
|
|
|
354
354
|
await user.type(searchInput, 'display');
|
|
355
355
|
await waitFor(() => expect(screen.getByText('Field display name')).toBeInTheDocument());
|
|
356
356
|
});
|
|
357
|
+
|
|
358
|
+
it('keeps searchable submenu children during IME composition', async () => {
|
|
359
|
+
const engine = new FlowEngine();
|
|
360
|
+
await engine.flowSettings.forceEnable();
|
|
361
|
+
class Parent extends FlowModel {}
|
|
362
|
+
engine.registerModels({ Parent });
|
|
363
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
364
|
+
|
|
365
|
+
const items = [
|
|
366
|
+
{
|
|
367
|
+
key: 'fields',
|
|
368
|
+
label: 'Fields',
|
|
369
|
+
searchable: true,
|
|
370
|
+
children: [
|
|
371
|
+
{ key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
|
|
372
|
+
{ key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
const user = userEvent.setup();
|
|
378
|
+
render(
|
|
379
|
+
<FlowEngineProvider engine={engine}>
|
|
380
|
+
<ConfigProvider>
|
|
381
|
+
<App>
|
|
382
|
+
<AddSubModelButton model={parent} subModelKey="items" items={items as any}>
|
|
383
|
+
Open
|
|
384
|
+
</AddSubModelButton>
|
|
385
|
+
</App>
|
|
386
|
+
</ConfigProvider>
|
|
387
|
+
</FlowEngineProvider>,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
await user.click(screen.getByText('Open'));
|
|
391
|
+
await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
|
|
392
|
+
await user.hover(screen.getByText('Fields'));
|
|
393
|
+
await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
|
|
394
|
+
|
|
395
|
+
const input = screen.getByRole('textbox');
|
|
396
|
+
await user.click(input);
|
|
397
|
+
fireEvent.compositionStart(input);
|
|
398
|
+
fireEvent.change(input, { target: { value: 'zzzz' }, nativeEvent: { isComposing: true } });
|
|
399
|
+
fireEvent.mouseLeave(screen.getByText('Fields'));
|
|
400
|
+
await act(async () => {
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(input).toHaveValue('zzzz');
|
|
405
|
+
expect(screen.getByText('Field 1')).toBeInTheDocument();
|
|
406
|
+
expect(screen.getByText('Field 2')).toBeInTheDocument();
|
|
407
|
+
|
|
408
|
+
fireEvent.compositionEnd(input);
|
|
409
|
+
fireEvent.change(input, { target: { value: 'zzzz' } });
|
|
410
|
+
|
|
411
|
+
await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('closes searchable submenu after focus without input', async () => {
|
|
415
|
+
const engine = new FlowEngine();
|
|
416
|
+
await engine.flowSettings.forceEnable();
|
|
417
|
+
class Parent extends FlowModel {}
|
|
418
|
+
engine.registerModels({ Parent });
|
|
419
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
420
|
+
|
|
421
|
+
const items = [
|
|
422
|
+
{
|
|
423
|
+
key: 'fields',
|
|
424
|
+
label: 'Fields',
|
|
425
|
+
searchable: true,
|
|
426
|
+
children: [
|
|
427
|
+
{ key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
|
|
428
|
+
{ key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
|
|
429
|
+
],
|
|
430
|
+
},
|
|
431
|
+
];
|
|
432
|
+
|
|
433
|
+
const user = userEvent.setup();
|
|
434
|
+
render(
|
|
435
|
+
<FlowEngineProvider engine={engine}>
|
|
436
|
+
<ConfigProvider>
|
|
437
|
+
<App>
|
|
438
|
+
<AddSubModelButton model={parent} subModelKey="items" items={items as any}>
|
|
439
|
+
Open
|
|
440
|
+
</AddSubModelButton>
|
|
441
|
+
</App>
|
|
442
|
+
</ConfigProvider>
|
|
443
|
+
</FlowEngineProvider>,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
await user.click(screen.getByText('Open'));
|
|
447
|
+
await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
|
|
448
|
+
await user.hover(screen.getByText('Fields'));
|
|
449
|
+
await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
|
|
450
|
+
|
|
451
|
+
await user.click(screen.getByRole('textbox'));
|
|
452
|
+
fireEvent.mouseLeave(screen.getByText('Fields'));
|
|
453
|
+
|
|
454
|
+
await waitFor(() => expect(screen.queryByText('Field 1')).not.toBeInTheDocument());
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('closes active searchable submenu after outside click', async () => {
|
|
458
|
+
const engine = new FlowEngine();
|
|
459
|
+
await engine.flowSettings.forceEnable();
|
|
460
|
+
class Parent extends FlowModel {}
|
|
461
|
+
engine.registerModels({ Parent });
|
|
462
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
463
|
+
|
|
464
|
+
const items = [
|
|
465
|
+
{
|
|
466
|
+
key: 'fields',
|
|
467
|
+
label: 'Fields',
|
|
468
|
+
searchable: true,
|
|
469
|
+
children: [
|
|
470
|
+
{ key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
|
|
471
|
+
{ key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
];
|
|
475
|
+
|
|
476
|
+
const user = userEvent.setup();
|
|
477
|
+
render(
|
|
478
|
+
<FlowEngineProvider engine={engine}>
|
|
479
|
+
<ConfigProvider>
|
|
480
|
+
<App>
|
|
481
|
+
<AddSubModelButton model={parent} subModelKey="items" items={items as any}>
|
|
482
|
+
Open
|
|
483
|
+
</AddSubModelButton>
|
|
484
|
+
</App>
|
|
485
|
+
</ConfigProvider>
|
|
486
|
+
</FlowEngineProvider>,
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
await user.click(screen.getByText('Open'));
|
|
490
|
+
await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
|
|
491
|
+
await user.hover(screen.getByText('Fields'));
|
|
492
|
+
await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
|
|
493
|
+
|
|
494
|
+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Field' } });
|
|
495
|
+
expect(screen.getByText('Field 1')).toBeInTheDocument();
|
|
496
|
+
|
|
497
|
+
fireEvent.pointerDown(document.body);
|
|
498
|
+
|
|
499
|
+
await waitFor(() => expect(screen.queryByText('Fields')).not.toBeInTheDocument());
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('switches away from active searchable submenu and resets its input', async () => {
|
|
503
|
+
const engine = new FlowEngine();
|
|
504
|
+
await engine.flowSettings.forceEnable();
|
|
505
|
+
class Parent extends FlowModel {}
|
|
506
|
+
engine.registerModels({ Parent });
|
|
507
|
+
const parent = engine.createModel<FlowModel>({ use: 'Parent' });
|
|
508
|
+
|
|
509
|
+
const items = [
|
|
510
|
+
{
|
|
511
|
+
key: 'fields',
|
|
512
|
+
label: 'Fields',
|
|
513
|
+
searchable: true,
|
|
514
|
+
children: [
|
|
515
|
+
{ key: 'f1', label: 'Field 1', createModelOptions: { use: 'Parent' } },
|
|
516
|
+
{ key: 'f2', label: 'Field 2', createModelOptions: { use: 'Parent' } },
|
|
517
|
+
],
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
key: 'blocks',
|
|
521
|
+
label: 'Blocks',
|
|
522
|
+
searchable: true,
|
|
523
|
+
children: [
|
|
524
|
+
{ key: 'b1', label: 'Block 1', createModelOptions: { use: 'Parent' } },
|
|
525
|
+
{ key: 'b2', label: 'Block 2', createModelOptions: { use: 'Parent' } },
|
|
526
|
+
],
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
|
|
530
|
+
const user = userEvent.setup();
|
|
531
|
+
render(
|
|
532
|
+
<FlowEngineProvider engine={engine}>
|
|
533
|
+
<ConfigProvider>
|
|
534
|
+
<App>
|
|
535
|
+
<AddSubModelButton model={parent} subModelKey="items" items={items as any}>
|
|
536
|
+
Open
|
|
537
|
+
</AddSubModelButton>
|
|
538
|
+
</App>
|
|
539
|
+
</ConfigProvider>
|
|
540
|
+
</FlowEngineProvider>,
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
await user.click(screen.getByText('Open'));
|
|
544
|
+
await waitFor(() => expect(screen.getByText('Fields')).toBeInTheDocument());
|
|
545
|
+
await user.hover(screen.getByText('Fields'));
|
|
546
|
+
await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
|
|
547
|
+
|
|
548
|
+
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzzz' } });
|
|
549
|
+
await waitFor(() => expect(screen.getAllByText('No data').length).toBeGreaterThan(0));
|
|
550
|
+
|
|
551
|
+
await user.hover(screen.getByText('Blocks'));
|
|
552
|
+
await waitFor(() => expect(screen.getByText('Block 1')).toBeInTheDocument());
|
|
553
|
+
expect(screen.queryByText('No data')).not.toBeInTheDocument();
|
|
554
|
+
|
|
555
|
+
await user.hover(screen.getByText('Fields'));
|
|
556
|
+
await waitFor(() => expect(screen.getByText('Field 1')).toBeInTheDocument());
|
|
557
|
+
expect(screen.getByRole('textbox')).toHaveValue('');
|
|
558
|
+
});
|
|
357
559
|
});
|
|
358
560
|
|
|
359
561
|
describe('transformItems - hide', () => {
|
package/src/data-source/index.ts
CHANGED
|
@@ -37,7 +37,13 @@ export class DataSourceManager {
|
|
|
37
37
|
addFieldInterfaceGroups?: (groups: Record<string, { label: string; order?: number }>) => void;
|
|
38
38
|
addFieldInterfaceComponentOption?: (name: string, option: any) => void;
|
|
39
39
|
addFieldInterfaceOperator?: (name: string, operator: any) => void;
|
|
40
|
+
registerFieldFilterOperator?: (operator: any) => void;
|
|
41
|
+
registerFieldFilterOperatorGroup?: (name: string, operators?: any[]) => void;
|
|
42
|
+
addFieldFilterOperatorsToGroup?: (name: string, operators?: any[]) => void;
|
|
40
43
|
getFieldInterface?: (name: string) => any;
|
|
44
|
+
registerFieldInterfaceConfigure?: (options: unknown) => void;
|
|
45
|
+
getFieldInterfaceConfigure?: (name: string, collectionInfo?: unknown) => unknown;
|
|
46
|
+
getFieldInterfaceConfigureProperties?: (name: string, collectionInfo?: any) => Record<string, any>;
|
|
41
47
|
};
|
|
42
48
|
loaders = new Map<string, DataSourceLoader>();
|
|
43
49
|
loadedKeys = new Set<string>();
|
|
@@ -77,6 +83,18 @@ export class DataSourceManager {
|
|
|
77
83
|
this.collectionFieldInterfaceManager?.addFieldInterfaceOperator?.(name, operator);
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
registerFieldFilterOperator(operator: any) {
|
|
87
|
+
this.collectionFieldInterfaceManager?.registerFieldFilterOperator?.(operator);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
registerFieldFilterOperatorGroup(name: string, operators: any[] = []) {
|
|
91
|
+
this.collectionFieldInterfaceManager?.registerFieldFilterOperatorGroup?.(name, operators);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
addFieldFilterOperatorsToGroup(name: string, operators: any[] = []) {
|
|
95
|
+
this.collectionFieldInterfaceManager?.addFieldFilterOperatorsToGroup?.(name, operators);
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
registerLoader(key: string, loader: DataSourceLoader) {
|
|
81
99
|
this.loaders.set(key, loader);
|
|
82
100
|
}
|
|
@@ -81,6 +81,34 @@ describe('FlowExecutor', () => {
|
|
|
81
81
|
expect(result.step2).toBe('step2-ok');
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
it('runFlow warns and skips steps without use or handler', async () => {
|
|
85
|
+
const flows = {
|
|
86
|
+
referenceSettings: {
|
|
87
|
+
steps: {
|
|
88
|
+
target: {},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
} satisfies Record<string, Omit<FlowDefinitionOptions, 'key'>>;
|
|
92
|
+
const model = createModelWithFlows('m-empty-step', flows);
|
|
93
|
+
const loggerChildSpy = vi.spyOn(engine.logger, 'child').mockReturnValue(engine.logger);
|
|
94
|
+
const loggerWarnSpy = vi.spyOn(engine.logger, 'warn').mockImplementation(() => {});
|
|
95
|
+
const loggerErrorSpy = vi.spyOn(engine.logger, 'error').mockImplementation(() => {});
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const result = await engine.executor.runFlow(model, 'referenceSettings');
|
|
99
|
+
|
|
100
|
+
expect(result).toEqual({});
|
|
101
|
+
expect(loggerWarnSpy).toHaveBeenCalledWith(
|
|
102
|
+
"BaseModel.applyFlow: Step 'target' in flow 'referenceSettings' has neither 'use' nor 'handler'. Skipping.",
|
|
103
|
+
);
|
|
104
|
+
expect(loggerErrorSpy).not.toHaveBeenCalled();
|
|
105
|
+
} finally {
|
|
106
|
+
loggerChildSpy.mockRestore();
|
|
107
|
+
loggerWarnSpy.mockRestore();
|
|
108
|
+
loggerErrorSpy.mockRestore();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
84
112
|
it("dispatchEvent('beforeRender') executes flows in sort order and caches result (when options specify)", async () => {
|
|
85
113
|
const calls: string[] = [];
|
|
86
114
|
const mkFlow = (key: string, sort: number) => ({
|
package/src/flowContext.ts
CHANGED
|
@@ -3581,6 +3581,9 @@ export class FlowEngineContext extends BaseFlowEngineContext {
|
|
|
3581
3581
|
},
|
|
3582
3582
|
});
|
|
3583
3583
|
this.defineMethod('aclCheck', function (params) {
|
|
3584
|
+
if (this.skipAclCheck) {
|
|
3585
|
+
return true;
|
|
3586
|
+
}
|
|
3584
3587
|
return this.acl.aclCheck(params);
|
|
3585
3588
|
});
|
|
3586
3589
|
this.defineMethod('createResource', function (this: BaseFlowEngineContext, resourceType) {
|
package/src/flowEngine.ts
CHANGED
|
@@ -39,6 +39,8 @@ import type {
|
|
|
39
39
|
} from './types';
|
|
40
40
|
import { isInheritedFrom } from './utils';
|
|
41
41
|
|
|
42
|
+
const getFlowEngineLoggerLevel = () => (process.env.NODE_ENV === 'production' ? 'warn' : 'trace');
|
|
43
|
+
|
|
42
44
|
/**
|
|
43
45
|
* FlowEngine is the core class of the flow engine, responsible for managing flow models, actions, model repository, and more.
|
|
44
46
|
* It provides capabilities for registering, creating, finding, persisting, replacing, and moving models.
|
|
@@ -213,7 +215,7 @@ export class FlowEngine {
|
|
|
213
215
|
MultiRecordResource,
|
|
214
216
|
});
|
|
215
217
|
this.logger = pino({
|
|
216
|
-
level:
|
|
218
|
+
level: getFlowEngineLoggerLevel(),
|
|
217
219
|
browser: {
|
|
218
220
|
write: {
|
|
219
221
|
fatal: (o) => console.trace(o),
|
|
@@ -1009,7 +1011,6 @@ export class FlowEngine {
|
|
|
1009
1011
|
|
|
1010
1012
|
while (current) {
|
|
1011
1013
|
if (visited.has(current)) {
|
|
1012
|
-
console.warn(`FlowEngine: resolveUse circular reference detected on '${current.name}'.`);
|
|
1013
1014
|
break;
|
|
1014
1015
|
}
|
|
1015
1016
|
visited.add(current);
|
|
@@ -1128,7 +1129,7 @@ export class FlowEngine {
|
|
|
1128
1129
|
*/
|
|
1129
1130
|
public removeModel(uid: string): boolean {
|
|
1130
1131
|
if (!this._modelInstances.has(uid)) {
|
|
1131
|
-
|
|
1132
|
+
this.logger.debug(`FlowEngine: Model with UID '${uid}' does not exist.`);
|
|
1132
1133
|
return false;
|
|
1133
1134
|
}
|
|
1134
1135
|
const modelInstance = this._modelInstances.get(uid) as FlowModel;
|
|
@@ -61,21 +61,6 @@ describe('FlowEngine.createModel resolveUse hook', () => {
|
|
|
61
61
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
test('should break resolveUse on circular reference and warn', () => {
|
|
65
|
-
class LoopModel extends FlowModel {
|
|
66
|
-
static resolveUse() {
|
|
67
|
-
return 'LoopModel';
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
engine.registerModels({ LoopModel });
|
|
72
|
-
|
|
73
|
-
const model = engine.createModel({ use: 'LoopModel', uid: 'loop-model', flowEngine: engine });
|
|
74
|
-
|
|
75
|
-
expect(model).toBeInstanceOf(LoopModel);
|
|
76
|
-
expect(warnSpy).toHaveBeenCalled();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
64
|
test('should fall back to ErrorFlowModel when resolveUse returns unregistered name', () => {
|
|
80
65
|
class MissingTargetEntry extends FlowModel {
|
|
81
66
|
static resolveUse() {
|
|
@@ -370,15 +370,17 @@ describe('FlowModel', () => {
|
|
|
370
370
|
};
|
|
371
371
|
|
|
372
372
|
TestFlowModel.registerFlow(exitFlow);
|
|
373
|
-
const
|
|
373
|
+
const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
|
|
374
374
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
expect(result).toBeInstanceOf(FlowExitAllException);
|
|
378
|
-
expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
|
|
379
|
-
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
|
|
375
|
+
try {
|
|
376
|
+
const result = await model.applyFlow('exitFlow');
|
|
380
377
|
|
|
381
|
-
|
|
378
|
+
expect(result).toBeInstanceOf(FlowExitAllException);
|
|
379
|
+
expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
|
|
380
|
+
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
|
|
381
|
+
} finally {
|
|
382
|
+
loggerSpy.mockRestore();
|
|
383
|
+
}
|
|
382
384
|
});
|
|
383
385
|
|
|
384
386
|
test('should handle ctx.exit() as FlowExitAllException in beforeRender dispatch', async () => {
|
|
@@ -474,15 +476,17 @@ describe('FlowModel', () => {
|
|
|
474
476
|
};
|
|
475
477
|
|
|
476
478
|
TestFlowModel.registerFlow(exitFlow);
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
const result = await model.applyFlow('exitFlow');
|
|
479
|
+
const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
|
|
480
480
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
|
|
481
|
+
try {
|
|
482
|
+
const result = await model.applyFlow('exitFlow');
|
|
484
483
|
|
|
485
|
-
|
|
484
|
+
expect(result).toBeInstanceOf(FlowExitAllException);
|
|
485
|
+
expect(exitFlow.steps.step2.handler).not.toHaveBeenCalled();
|
|
486
|
+
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('[FlowModel]'));
|
|
487
|
+
} finally {
|
|
488
|
+
loggerSpy.mockRestore();
|
|
489
|
+
}
|
|
486
490
|
});
|
|
487
491
|
|
|
488
492
|
test('should propagate step execution errors', async () => {
|
|
@@ -796,7 +800,7 @@ describe('FlowModel', () => {
|
|
|
796
800
|
const eventFlow = createEventFlowDefinition('testEvent');
|
|
797
801
|
TestFlowModel.registerFlow(eventFlow);
|
|
798
802
|
|
|
799
|
-
const
|
|
803
|
+
const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
|
|
800
804
|
|
|
801
805
|
try {
|
|
802
806
|
model.dispatchEvent('testEvent', { data: 'payload' });
|
|
@@ -804,7 +808,7 @@ describe('FlowModel', () => {
|
|
|
804
808
|
// Use a more reliable approach than arbitrary timeout
|
|
805
809
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
806
810
|
|
|
807
|
-
expect(
|
|
811
|
+
expect(loggerSpy).toHaveBeenCalledWith(
|
|
808
812
|
expect.stringContaining('[FlowModel] dispatchEvent: uid=test-model-uid, event=testEvent'),
|
|
809
813
|
);
|
|
810
814
|
expect(eventFlow.steps.eventStep.handler).toHaveBeenCalledWith(
|
|
@@ -814,7 +818,7 @@ describe('FlowModel', () => {
|
|
|
814
818
|
expect.any(Object),
|
|
815
819
|
);
|
|
816
820
|
} finally {
|
|
817
|
-
|
|
821
|
+
loggerSpy.mockRestore();
|
|
818
822
|
}
|
|
819
823
|
});
|
|
820
824
|
|
|
@@ -1625,7 +1629,7 @@ describe('FlowModel', () => {
|
|
|
1625
1629
|
fork1.dispose = vi.fn();
|
|
1626
1630
|
fork2.dispose = vi.fn();
|
|
1627
1631
|
|
|
1628
|
-
const
|
|
1632
|
+
const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
|
|
1629
1633
|
|
|
1630
1634
|
try {
|
|
1631
1635
|
model.clearForks();
|
|
@@ -1634,19 +1638,19 @@ describe('FlowModel', () => {
|
|
|
1634
1638
|
expect(fork2.dispose).toHaveBeenCalled();
|
|
1635
1639
|
expect(model.forks.size).toBe(0);
|
|
1636
1640
|
} finally {
|
|
1637
|
-
|
|
1641
|
+
loggerSpy.mockRestore();
|
|
1638
1642
|
}
|
|
1639
1643
|
});
|
|
1640
1644
|
|
|
1641
1645
|
test('should handle empty forks collection when clearing', () => {
|
|
1642
|
-
const
|
|
1646
|
+
const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
|
|
1643
1647
|
|
|
1644
1648
|
try {
|
|
1645
1649
|
model.clearForks();
|
|
1646
1650
|
|
|
1647
1651
|
expect(model.forks.size).toBe(0);
|
|
1648
1652
|
} finally {
|
|
1649
|
-
|
|
1653
|
+
loggerSpy.mockRestore();
|
|
1650
1654
|
}
|
|
1651
1655
|
});
|
|
1652
1656
|
});
|
|
@@ -1774,7 +1778,7 @@ describe('FlowModel', () => {
|
|
|
1774
1778
|
test('should clean up resources on remove', () => {
|
|
1775
1779
|
model.createFork();
|
|
1776
1780
|
model.createFork();
|
|
1777
|
-
const
|
|
1781
|
+
const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
|
|
1778
1782
|
|
|
1779
1783
|
// Mock removeModel to simulate proper fork cleanup
|
|
1780
1784
|
flowEngine.removeModel = vi.fn().mockImplementation(() => {
|
|
@@ -1791,7 +1795,7 @@ describe('FlowModel', () => {
|
|
|
1791
1795
|
expect(model.forks.size).toBe(0);
|
|
1792
1796
|
expect(flowEngine.removeModel).toHaveBeenCalledWith(model.uid);
|
|
1793
1797
|
} finally {
|
|
1794
|
-
|
|
1798
|
+
loggerSpy.mockRestore();
|
|
1795
1799
|
}
|
|
1796
1800
|
});
|
|
1797
1801
|
});
|
|
@@ -1868,17 +1872,12 @@ describe('FlowModel', () => {
|
|
|
1868
1872
|
});
|
|
1869
1873
|
|
|
1870
1874
|
test('should rerender triggers beforeRender without cache', async () => {
|
|
1871
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
1872
1875
|
model.dispatchEvent = vi.fn().mockResolvedValue(undefined) as any;
|
|
1873
1876
|
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
});
|
|
1879
|
-
} finally {
|
|
1880
|
-
consoleSpy.mockRestore();
|
|
1881
|
-
}
|
|
1877
|
+
await expect(model.rerender()).resolves.not.toThrow();
|
|
1878
|
+
expect(model.dispatchEvent).toHaveBeenCalledWith('beforeRender', undefined, {
|
|
1879
|
+
useCache: false,
|
|
1880
|
+
});
|
|
1882
1881
|
});
|
|
1883
1882
|
});
|
|
1884
1883
|
|
|
@@ -2918,7 +2917,7 @@ describe('FlowModel', () => {
|
|
|
2918
2917
|
describe('Edge Cases & Error Handling', () => {
|
|
2919
2918
|
test('should handle model destruction gracefully', () => {
|
|
2920
2919
|
const model = new FlowModel(modelOptions);
|
|
2921
|
-
const
|
|
2920
|
+
const loggerSpy = vi.spyOn(model.flowEngine.logger, 'debug').mockImplementation(() => {});
|
|
2922
2921
|
|
|
2923
2922
|
model.createFork();
|
|
2924
2923
|
model.setProps({ testProp: 'value' });
|
|
@@ -2926,7 +2925,7 @@ describe('FlowModel', () => {
|
|
|
2926
2925
|
try {
|
|
2927
2926
|
expect(() => model.remove()).not.toThrow();
|
|
2928
2927
|
} finally {
|
|
2929
|
-
|
|
2928
|
+
loggerSpy.mockRestore();
|
|
2930
2929
|
}
|
|
2931
2930
|
});
|
|
2932
2931
|
|
package/src/models/flowModel.tsx
CHANGED
|
@@ -823,7 +823,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
823
823
|
}
|
|
824
824
|
const isFork = (this as any).isFork === true;
|
|
825
825
|
const target = this;
|
|
826
|
-
|
|
826
|
+
currentFlowEngine.logger.debug(
|
|
827
827
|
`[FlowModel] applyFlow: uid=${this.uid}, flowKey=${flowKey}, isFork=${isFork}, cleanRun=${
|
|
828
828
|
this.cleanRun
|
|
829
829
|
}, targetIsFork=${(target as any)?.isFork === true}`,
|
|
@@ -843,7 +843,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
843
843
|
}
|
|
844
844
|
const isFork = (this as any).isFork === true;
|
|
845
845
|
const target = this;
|
|
846
|
-
|
|
846
|
+
currentFlowEngine.logger.debug(
|
|
847
847
|
`[FlowModel] dispatchEvent: uid=${this.uid}, event=${eventName}, isFork=${isFork}, cleanRun=${
|
|
848
848
|
this.cleanRun
|
|
849
849
|
}, targetIsFork=${(target as any)?.isFork === true}`,
|
|
@@ -1379,7 +1379,7 @@ export class FlowModel<Structure extends DefaultStructure = DefaultStructure> {
|
|
|
1379
1379
|
}
|
|
1380
1380
|
|
|
1381
1381
|
clearForks() {
|
|
1382
|
-
|
|
1382
|
+
this.flowEngine.logger.debug(`FlowModel ${this.uid} clearing all forks.`);
|
|
1383
1383
|
// 主动使所有 fork 失效
|
|
1384
1384
|
if (this.forks?.size) {
|
|
1385
1385
|
this.forks.forEach((fork) => fork.dispose());
|
|
@@ -102,6 +102,27 @@ describe('parsePathnameToViewParams', () => {
|
|
|
102
102
|
expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }]);
|
|
103
103
|
});
|
|
104
104
|
|
|
105
|
+
test('should parse custom root prefix', () => {
|
|
106
|
+
const result = parsePathnameToViewParams('/embed/xxx/tab/yyy/view/zzz', { rootPrefix: 'embed' });
|
|
107
|
+
expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }, { viewUid: 'zzz' }]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('should parse pathname by basePath', () => {
|
|
111
|
+
const result = parsePathnameToViewParams('/embed/xxx/tab/yyy/view/zzz', { basePath: '/embed' });
|
|
112
|
+
expect(result).toEqual([{ viewUid: 'xxx', tabUid: 'yyy' }, { viewUid: 'zzz' }]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('should parse pathname by nested basePath', () => {
|
|
116
|
+
const result = parsePathnameToViewParams('/admin/settings/public-forms/xxx/view/zzz', {
|
|
117
|
+
basePath: '/admin/settings/public-forms',
|
|
118
|
+
});
|
|
119
|
+
expect(result).toEqual([{ viewUid: 'xxx' }, { viewUid: 'zzz' }]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('should keep admin as default root prefix', () => {
|
|
123
|
+
expect(parsePathnameToViewParams('/embed/xxx')).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
105
126
|
test('should parse filterByTk from key-value encoded segment into object', () => {
|
|
106
127
|
const kv = encodeURIComponent('id=1&tenant=ac');
|
|
107
128
|
const path = `/admin/xxx/filterbytk/${kv}`;
|
|
@@ -18,6 +18,35 @@ export interface ViewParam {
|
|
|
18
18
|
sourceId?: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface ParsePathnameToViewParamsOptions {
|
|
22
|
+
rootPrefix?: string;
|
|
23
|
+
basePath?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const normalizePathname = (pathname: string) => {
|
|
27
|
+
if (!pathname || pathname === '/') {
|
|
28
|
+
return '/';
|
|
29
|
+
}
|
|
30
|
+
return `/${pathname.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const normalizeBasePath = (basePath: string) => `/${basePath.replace(/^\/+/, '').replace(/\/+$/, '')}`;
|
|
34
|
+
|
|
35
|
+
const stripBasePath = (pathname: string, basePath: string) => {
|
|
36
|
+
const normalizedPathname = normalizePathname(pathname);
|
|
37
|
+
const normalizedBasePath = normalizeBasePath(basePath);
|
|
38
|
+
|
|
39
|
+
if (normalizedPathname === normalizedBasePath) {
|
|
40
|
+
return '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (normalizedPathname.startsWith(`${normalizedBasePath}/`)) {
|
|
44
|
+
return normalizedPathname.slice(normalizedBasePath.length + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return '';
|
|
48
|
+
};
|
|
49
|
+
|
|
21
50
|
/**
|
|
22
51
|
* 解析路径名为视图参数数组
|
|
23
52
|
*
|
|
@@ -33,15 +62,21 @@ export interface ViewParam {
|
|
|
33
62
|
* parsePathnameToViewParams('/admin/xxx/view/yyy') // [{ viewUid: 'xxx' }, { viewUid: 'yyy' }]
|
|
34
63
|
* ```
|
|
35
64
|
*/
|
|
36
|
-
export const parsePathnameToViewParams = (
|
|
65
|
+
export const parsePathnameToViewParams = (
|
|
66
|
+
pathname: string,
|
|
67
|
+
options: ParsePathnameToViewParamsOptions = {},
|
|
68
|
+
): ViewParam[] => {
|
|
37
69
|
if (!pathname || pathname === '/') {
|
|
38
70
|
return [];
|
|
39
71
|
}
|
|
40
72
|
|
|
73
|
+
const rootPrefix = options.rootPrefix || 'admin';
|
|
74
|
+
const relativePath = options.basePath ? stripBasePath(pathname, options.basePath) : '';
|
|
75
|
+
|
|
41
76
|
// 移除开头的斜杠并分割路径
|
|
42
|
-
const segments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
77
|
+
const segments = (options.basePath ? relativePath : pathname).replace(/^\/+/, '').split('/').filter(Boolean);
|
|
43
78
|
|
|
44
|
-
if (segments.length < 2) {
|
|
79
|
+
if (segments.length < (options.basePath ? 1 : 2)) {
|
|
45
80
|
return [];
|
|
46
81
|
}
|
|
47
82
|
|
|
@@ -49,11 +84,16 @@ export const parsePathnameToViewParams = (pathname: string): ViewParam[] => {
|
|
|
49
84
|
let currentView: ViewParam | null = null;
|
|
50
85
|
let i = 0;
|
|
51
86
|
|
|
87
|
+
if (options.basePath) {
|
|
88
|
+
currentView = { viewUid: segments[0] };
|
|
89
|
+
i = 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
52
92
|
while (i < segments.length) {
|
|
53
93
|
const segment = segments[i];
|
|
54
94
|
|
|
55
|
-
//
|
|
56
|
-
if (segment ===
|
|
95
|
+
// 处理布局根前缀或 view 关键字
|
|
96
|
+
if (segment === rootPrefix || segment === 'view') {
|
|
57
97
|
// 如果有当前视图,先保存到结果中
|
|
58
98
|
if (currentView) {
|
|
59
99
|
result.push(currentView);
|