@nocobase/client-v2 2.4.0-alpha.1 → 2.4.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/es/RouteRepository.d.ts +6 -0
- package/es/components/form/ScanInput/useCodeScanner.d.ts +12 -2
- package/es/components/form/ScanInput/zxingWasmDecoder.d.ts +9 -0
- package/es/flow/components/FieldAssignValueInput.d.ts +2 -0
- package/es/flow/components/field-value-variable/FieldValueVariableInput.d.ts +1 -0
- package/es/flow/components/filter/VariableFilterItem.d.ts +10 -1
- package/es/flow/internal/utils/quickEditDataScope.d.ts +48 -0
- package/es/flow/internal/utils/quickEditViewContainer.d.ts +27 -0
- package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +1 -0
- package/es/index.mjs +77 -77
- package/lib/index.js +131 -131
- package/package.json +9 -8
- package/src/BaseApplication.tsx +2 -0
- package/src/RouteRepository.ts +14 -0
- package/src/__tests__/RouteRepository.test.ts +51 -0
- package/src/__tests__/app.test.tsx +13 -0
- package/src/collection-manager/__tests__/field-configure.test.ts +52 -0
- package/src/collection-manager/field-configure.ts +2 -2
- package/src/components/form/ScanInput/CodeScanner.tsx +3 -1
- package/src/components/form/ScanInput/__tests__/CodeScanner.test.tsx +7 -1
- package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +182 -8
- package/src/components/form/ScanInput/__tests__/zxingWasmDecoder.test.ts +78 -0
- package/src/components/form/ScanInput/useCodeScanner.ts +135 -20
- package/src/components/form/ScanInput/zxingWasmDecoder.ts +64 -0
- package/src/flow/actions/__tests__/validation.test.ts +48 -0
- package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +39 -0
- package/src/flow/components/FieldAssignValueInput.tsx +4 -0
- package/src/flow/components/field-value-variable/FieldValueVariableInput.tsx +21 -12
- package/src/flow/components/field-value-variable/__tests__/FieldValueVariableInput.test.tsx +9 -0
- package/src/flow/components/filter/VariableFilterItem.tsx +14 -4
- package/src/flow/components/filter/__tests__/VariableFilterItem.rightMetaTree.test.tsx +158 -0
- package/src/flow/internal/utils/__tests__/quickEditDataScope.test.ts +156 -0
- package/src/flow/internal/utils/__tests__/quickEditViewContainer.test.ts +52 -0
- package/src/flow/internal/utils/quickEditDataScope.tsx +177 -0
- package/src/flow/internal/utils/quickEditViewContainer.ts +46 -0
- package/src/flow/models/actions/JSItemActionModel.tsx +19 -3
- package/src/flow/models/actions/__tests__/JSItemActionModel.test.tsx +47 -1
- package/src/flow/models/base/GridModel.tsx +0 -1
- package/src/flow/models/blocks/assign-form/AssignFormGridModel.tsx +22 -1
- package/src/flow/models/blocks/assign-form/AssignFormItemModel.tsx +14 -3
- package/src/flow/models/blocks/assign-form/__tests__/assignFieldValuesFlow.editor.test.tsx +140 -0
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.toggleFormFieldsCollapse.test.ts +29 -0
- package/src/flow/models/blocks/filter-form/fields/FieldComponentProps.tsx +1 -1
- package/src/flow/models/blocks/filter-form/fields/__tests__/FieldComponentProps.options.test.tsx +61 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +26 -5
- package/src/flow/models/blocks/form/QuickEditFormModel.tsx +45 -11
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +145 -0
- package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +120 -0
- package/src/flow/models/blocks/form/__tests__/popupLinkage.test.tsx +175 -0
- package/src/flow/models/blocks/js-block/JSBlock.tsx +5 -1
- package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +23 -1
- package/src/flow/models/blocks/table/TableColumnModel.tsx +31 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +64 -0
- package/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +7 -1
- package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +4 -3
- package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +9 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +47 -17
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +6 -1
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.largeField.test.tsx +178 -0
- package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableField.disabled.test.tsx +90 -0
- package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordSelectFieldModel.quickCreatePopup.test.tsx +183 -0
- package/src/flow/models/fields/DisplayAssociationField/DisplaySubTableFieldModel.tsx +42 -7
- package/src/flow/models/fields/DisplayAssociationField/__tests__/DisplaySubTableFieldModel.test.tsx +351 -0
- package/src/flow/models/fields/SelectFieldModel.tsx +5 -0
- package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +29 -1
|
@@ -77,6 +77,7 @@ async function setupFormModel() {
|
|
|
77
77
|
ds.addCollection({
|
|
78
78
|
name: 'levels',
|
|
79
79
|
filterTargetKey: 'id',
|
|
80
|
+
titleField: 'name',
|
|
80
81
|
fields: [
|
|
81
82
|
{ name: 'id', type: 'integer', interface: 'number' },
|
|
82
83
|
{ name: 'name', type: 'string', interface: 'text' },
|
|
@@ -549,6 +550,150 @@ describe('FormBlockModel (form/formValues injection & server resolve anchors)',
|
|
|
549
550
|
expect(api.request).toHaveBeenCalledTimes(1);
|
|
550
551
|
});
|
|
551
552
|
|
|
553
|
+
it('resolves a configured nested association from the server when the local value is only its key', async () => {
|
|
554
|
+
const model = await setupFormModel();
|
|
555
|
+
const api = {
|
|
556
|
+
request: vi.fn(async (config: any) => {
|
|
557
|
+
const batch = config?.data?.values?.batch || [];
|
|
558
|
+
const item = batch[0] || {};
|
|
559
|
+
expect(item.template).toEqual({ level: '{{ ctx.formValues.customer.level }}' });
|
|
560
|
+
expect(Object.keys(item.contextParams || {})).toEqual(['formValues.customer']);
|
|
561
|
+
expect(item.contextParams['formValues.customer']).toMatchObject({
|
|
562
|
+
collection: 'customers',
|
|
563
|
+
filterByTk: 9,
|
|
564
|
+
});
|
|
565
|
+
return {
|
|
566
|
+
data: {
|
|
567
|
+
data: {
|
|
568
|
+
results: [{ id: item.id, data: { level: { id: 'level-1', name: 'Level 1' } } }],
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
} as any;
|
|
572
|
+
}),
|
|
573
|
+
} as any;
|
|
574
|
+
(model.flowEngine.context as any).defineProperty('api', { value: api });
|
|
575
|
+
|
|
576
|
+
function HookCaller() {
|
|
577
|
+
model.useHooksBeforeRender();
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
render(React.createElement(HookCaller));
|
|
581
|
+
|
|
582
|
+
const mem: Record<string, any> = {};
|
|
583
|
+
const fakeForm = {
|
|
584
|
+
setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
|
|
585
|
+
getFieldsValue: () => ({ ...mem }),
|
|
586
|
+
getFieldValue: (namePath: any) => getByPath(mem, namePath),
|
|
587
|
+
setFieldValue: (key: string, value: any) => (mem[key] = value),
|
|
588
|
+
};
|
|
589
|
+
(model.context as any).defineProperty('form', { value: fakeForm });
|
|
590
|
+
fakeForm.setFieldsValue({ customer: { id: 9, level: 'level-1' } });
|
|
591
|
+
mockFormGridEnabledFields(model, ['customer']);
|
|
592
|
+
|
|
593
|
+
const output = await (model.context as any).resolveJsonTemplate({
|
|
594
|
+
level: '{{ ctx.formValues.customer.level }}',
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
expect(api.request).toHaveBeenCalledTimes(1);
|
|
598
|
+
expect(output).toEqual({ level: { id: 'level-1', name: 'Level 1' } });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it('resolves a configured nested association when its local record does not include the title field', async () => {
|
|
602
|
+
const model = await setupFormModel();
|
|
603
|
+
const api = {
|
|
604
|
+
request: vi.fn(async (config: any) => {
|
|
605
|
+
const item = config?.data?.values?.batch?.[0] || {};
|
|
606
|
+
return {
|
|
607
|
+
data: {
|
|
608
|
+
data: {
|
|
609
|
+
results: [{ id: item.id, data: { level: { id: 'level-1', name: 'Level 1' } } }],
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
} as any;
|
|
613
|
+
}),
|
|
614
|
+
} as any;
|
|
615
|
+
(model.flowEngine.context as any).defineProperty('api', { value: api });
|
|
616
|
+
|
|
617
|
+
function HookCaller() {
|
|
618
|
+
model.useHooksBeforeRender();
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
render(React.createElement(HookCaller));
|
|
622
|
+
|
|
623
|
+
const mem: Record<string, any> = {};
|
|
624
|
+
const fakeForm = {
|
|
625
|
+
setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
|
|
626
|
+
getFieldsValue: () => ({ ...mem }),
|
|
627
|
+
getFieldValue: (namePath: any) => getByPath(mem, namePath),
|
|
628
|
+
setFieldValue: (key: string, value: any) => (mem[key] = value),
|
|
629
|
+
};
|
|
630
|
+
(model.context as any).defineProperty('form', { value: fakeForm });
|
|
631
|
+
fakeForm.setFieldsValue({ customer: { id: 9, level: { id: 'level-1' } } });
|
|
632
|
+
mockFormGridEnabledFields(model, ['customer']);
|
|
633
|
+
|
|
634
|
+
const output = await (model.context as any).resolveJsonTemplate({
|
|
635
|
+
level: '{{ ctx.formValues.customer.level }}',
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
expect(api.request).toHaveBeenCalledTimes(1);
|
|
639
|
+
expect(output).toEqual({ level: { id: 'level-1', name: 'Level 1' } });
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('uses a configured nested association locally when its record is already loaded', async () => {
|
|
643
|
+
const model = await setupFormModel();
|
|
644
|
+
const api = { request: vi.fn(async () => ({ data: {} }) as any) } as any;
|
|
645
|
+
(model.flowEngine.context as any).defineProperty('api', { value: api });
|
|
646
|
+
|
|
647
|
+
function HookCaller() {
|
|
648
|
+
model.useHooksBeforeRender();
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
render(React.createElement(HookCaller));
|
|
652
|
+
|
|
653
|
+
const mem: Record<string, any> = {};
|
|
654
|
+
const fakeForm = {
|
|
655
|
+
setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
|
|
656
|
+
getFieldsValue: () => ({ ...mem }),
|
|
657
|
+
getFieldValue: (namePath: any) => getByPath(mem, namePath),
|
|
658
|
+
setFieldValue: (key: string, value: any) => (mem[key] = value),
|
|
659
|
+
};
|
|
660
|
+
(model.context as any).defineProperty('form', { value: fakeForm });
|
|
661
|
+
const level = { id: 'level-1', name: 'Level 1' };
|
|
662
|
+
fakeForm.setFieldsValue({ customer: { id: 9, level } });
|
|
663
|
+
mockFormGridEnabledFields(model, ['customer']);
|
|
664
|
+
|
|
665
|
+
const output = await (model.context as any).resolveJsonTemplate({
|
|
666
|
+
level: '{{ ctx.formValues.customer.level }}',
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
expect(output).toEqual({ level });
|
|
670
|
+
expect(api.request).not.toHaveBeenCalled();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('keeps indexed paths local when the nested association record is already loaded', async () => {
|
|
674
|
+
const model = await setupFormModel();
|
|
675
|
+
|
|
676
|
+
function HookCaller() {
|
|
677
|
+
model.useHooksBeforeRender();
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
render(React.createElement(HookCaller));
|
|
681
|
+
|
|
682
|
+
const mem: Record<string, any> = {};
|
|
683
|
+
const fakeForm = {
|
|
684
|
+
setFieldsValue: (values: Record<string, any>) => Object.assign(mem, values),
|
|
685
|
+
getFieldsValue: () => ({ ...mem }),
|
|
686
|
+
getFieldValue: (namePath: any) => getByPath(mem, namePath),
|
|
687
|
+
setFieldValue: (key: string, value: any) => (mem[key] = value),
|
|
688
|
+
};
|
|
689
|
+
(model.context as any).defineProperty('form', { value: fakeForm });
|
|
690
|
+
fakeForm.setFieldsValue({ assignees: [{ id: 3, org: { id: 1, name: 'Org 1' } }] });
|
|
691
|
+
mockFormGridEnabledFields(model, ['assignees']);
|
|
692
|
+
|
|
693
|
+
const options = (model.context as any).getPropertyOptions('formValues');
|
|
694
|
+
expect(options.resolveOnServer('assignees[0].org.name')).toBe(false);
|
|
695
|
+
});
|
|
696
|
+
|
|
552
697
|
it('configured toMany dot aggregation path uses local value and skips server', async () => {
|
|
553
698
|
const model = await setupFormModel();
|
|
554
699
|
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
11
11
|
import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
|
|
12
|
+
import '../../../index';
|
|
12
13
|
import { QuickEditFormModel } from '../QuickEditFormModel';
|
|
14
|
+
import { dataScope } from '../../../../actions/dataScope';
|
|
15
|
+
import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel';
|
|
16
|
+
import { SelectFieldModel } from '../../../fields/SelectFieldModel';
|
|
13
17
|
|
|
14
18
|
describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
|
|
15
19
|
let engine: FlowEngine;
|
|
@@ -55,6 +59,54 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
|
|
|
55
59
|
});
|
|
56
60
|
};
|
|
57
61
|
|
|
62
|
+
const addMemberCollections = () => {
|
|
63
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
64
|
+
ds.addCollection({
|
|
65
|
+
name: 'departments',
|
|
66
|
+
filterTargetKey: 'id',
|
|
67
|
+
titleField: 'name',
|
|
68
|
+
fields: [
|
|
69
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
70
|
+
{ name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } },
|
|
71
|
+
{ name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
ds.addCollection({
|
|
75
|
+
name: 'members',
|
|
76
|
+
filterTargetKey: 'id',
|
|
77
|
+
fields: [
|
|
78
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
79
|
+
{ name: 'level', type: 'string', interface: 'input', uiSchema: { title: 'Level' } },
|
|
80
|
+
{
|
|
81
|
+
name: 'department',
|
|
82
|
+
type: 'belongsTo',
|
|
83
|
+
interface: 'm2o',
|
|
84
|
+
target: 'departments',
|
|
85
|
+
targetKey: 'id',
|
|
86
|
+
uiSchema: { title: 'Department' },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const openQuickEditForAssociation = async (dataScopeFilter?: Record<string, unknown>) => {
|
|
93
|
+
engine.registerModels({ QuickEditFormModel, RecordSelectFieldModel });
|
|
94
|
+
engine.registerActions({ dataScope });
|
|
95
|
+
addMemberCollections();
|
|
96
|
+
const model = engine.createModel<QuickEditFormModel>({
|
|
97
|
+
use: QuickEditFormModel,
|
|
98
|
+
uid: `qe-association-${dataScopeFilter ? 'scoped' : 'plain'}`,
|
|
99
|
+
stepParams: {
|
|
100
|
+
quickEditFormSettings: {
|
|
101
|
+
init: { dataSourceKey: 'main', collectionName: 'members', fieldPath: 'department' },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
model._dataScopeFilter = dataScopeFilter;
|
|
106
|
+
await model.applyFlow('quickEditFormSettings', { filterByTk: 7, record: { id: 7, level: 'senior' } });
|
|
107
|
+
return model;
|
|
108
|
+
};
|
|
109
|
+
|
|
58
110
|
it('uses source field context when opening quick edit', async () => {
|
|
59
111
|
engine.registerModels({ QuickEditFormModel });
|
|
60
112
|
engine.context.defineProperty('pageActive', { value: { value: false } });
|
|
@@ -414,4 +466,72 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
|
|
|
414
466
|
|
|
415
467
|
expect(api.request).toHaveBeenCalledTimes(1);
|
|
416
468
|
});
|
|
469
|
+
|
|
470
|
+
it('opens dropdown editors upwards in the desktop popover so they do not cover the actions', async () => {
|
|
471
|
+
engine.registerModels({ QuickEditFormModel, SelectFieldModel });
|
|
472
|
+
const ds = engine.context.dataSourceManager.getDataSource('main');
|
|
473
|
+
ds.addCollection({
|
|
474
|
+
name: 'users',
|
|
475
|
+
fields: [
|
|
476
|
+
{
|
|
477
|
+
name: 'tags',
|
|
478
|
+
type: 'array',
|
|
479
|
+
interface: 'multipleSelect',
|
|
480
|
+
uiSchema: { title: 'Tags', enum: [{ label: 'A', value: 'a' }] },
|
|
481
|
+
},
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const desktopModel = engine.createModel<QuickEditFormModel>({
|
|
486
|
+
use: QuickEditFormModel,
|
|
487
|
+
uid: 'qe-desktop',
|
|
488
|
+
stepParams: {
|
|
489
|
+
quickEditFormSettings: {
|
|
490
|
+
init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
await desktopModel.applyFlow('quickEditFormSettings', { record: {} });
|
|
495
|
+
const desktopField = desktopModel.subModels.fields[0];
|
|
496
|
+
expect(desktopField.use).toBe('SelectFieldModel');
|
|
497
|
+
expect(desktopField.props.placement).toBe('topLeft');
|
|
498
|
+
|
|
499
|
+
const mobileModel = engine.createModel<QuickEditFormModel>({
|
|
500
|
+
use: QuickEditFormModel,
|
|
501
|
+
uid: 'qe-mobile',
|
|
502
|
+
stepParams: {
|
|
503
|
+
quickEditFormSettings: {
|
|
504
|
+
init: { dataSourceKey: 'main', collectionName: 'users', fieldPath: 'tags' },
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
mobileModel.context.defineProperty('isMobileLayout', { value: true });
|
|
509
|
+
await mobileModel.applyFlow('quickEditFormSettings', { record: {} });
|
|
510
|
+
expect(mobileModel.subModels.fields[0].props.placement).toBeUndefined();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('hands the column data scope to the association editor built for the popover', async () => {
|
|
514
|
+
const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
|
|
515
|
+
|
|
516
|
+
const model = await openQuickEditForAssociation(filter);
|
|
517
|
+
|
|
518
|
+
const fieldModel = model.subModels.fields[0];
|
|
519
|
+
expect(fieldModel.use).toBe('RecordSelectFieldModel');
|
|
520
|
+
expect(fieldModel.getStepParams('selectSettings', 'dataScope')).toEqual({ filter });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('leaves the association editor unscoped when the column has no data scope', async () => {
|
|
524
|
+
const model = await openQuickEditForAssociation();
|
|
525
|
+
|
|
526
|
+
expect(model.subModels.fields[0].getStepParams('selectSettings', 'dataScope')).toBeUndefined();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('seeds the edited row before the editor flows run so record variables resolve', async () => {
|
|
530
|
+
const filter = { logic: '$and', items: [{ path: 'level', operator: 'eq', value: '{{ctx.record.level}}' }] };
|
|
531
|
+
|
|
532
|
+
const model = await openQuickEditForAssociation(filter);
|
|
533
|
+
|
|
534
|
+
expect(model.resource.getData()).toMatchObject({ id: 7, level: 'senior' });
|
|
535
|
+
expect(JSON.stringify(model.subModels.fields[0].resource.getFilter() ?? null)).toContain('senior');
|
|
536
|
+
});
|
|
417
537
|
});
|
|
@@ -0,0 +1,175 @@
|
|
|
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 React from 'react';
|
|
11
|
+
import { act, render, screen, waitFor, cleanup } from '@testing-library/react';
|
|
12
|
+
import { Form, Input } from 'antd';
|
|
13
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
14
|
+
import { FlowEngine, SingleRecordResource } from '@nocobase/flow-engine';
|
|
15
|
+
import { generateFlowModelRdFromToken } from '@nocobase/utils/client';
|
|
16
|
+
import { CreateFormModel, EditFormModel, FormGridModel, FormItemModel, FormComponent } from '../../../..';
|
|
17
|
+
import { fieldLinkageRules, linkageAssignField } from '../../../../actions/linkageRules';
|
|
18
|
+
|
|
19
|
+
afterEach(cleanup);
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
{ use: 'CreateFormModel', mode: 'default', expected: 'STAFF-001' },
|
|
23
|
+
{ use: 'EditFormModel', mode: 'default', expected: '' },
|
|
24
|
+
{ use: 'EditFormModel', mode: 'override', expected: 'STAFF-001' },
|
|
25
|
+
])('$use respects $mode linkage after a delayed popup variable response', async ({ use, mode, expected }) => {
|
|
26
|
+
const uid = `popup-${use}-${mode}`;
|
|
27
|
+
const engine = new FlowEngine();
|
|
28
|
+
engine.registerModels({ CreateFormModel, EditFormModel, FormGridModel, FormItemModel });
|
|
29
|
+
engine.registerActions({ fieldLinkageRules, linkageAssignField });
|
|
30
|
+
engine.context.dataSourceManager.getDataSource('main').addCollection({
|
|
31
|
+
name: 't1_user',
|
|
32
|
+
filterTargetKey: 'id',
|
|
33
|
+
fields: [
|
|
34
|
+
{ name: 'id', type: 'integer', interface: 'number' },
|
|
35
|
+
{ name: 'staffseq', type: 'string', interface: 'input' },
|
|
36
|
+
{ name: 'staffname', type: 'string', interface: 'input' },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
const form = engine.createModel<CreateFormModel | EditFormModel>({
|
|
40
|
+
uid,
|
|
41
|
+
use,
|
|
42
|
+
stepParams: { resourceSettings: { init: { dataSourceKey: 'main', collectionName: 't1_user', filterByTk: 1 } } },
|
|
43
|
+
subModels: {
|
|
44
|
+
grid: {
|
|
45
|
+
uid: `${uid}-grid`,
|
|
46
|
+
use: 'FormGridModel',
|
|
47
|
+
subModels: {
|
|
48
|
+
items: [
|
|
49
|
+
{
|
|
50
|
+
uid: `${uid}-staffname`,
|
|
51
|
+
use: 'FormItemModel',
|
|
52
|
+
props: { name: 'staffname' },
|
|
53
|
+
stepParams: {
|
|
54
|
+
fieldSettings: { init: { dataSourceKey: 'main', collectionName: 't1_user', fieldPath: 'staffname' } },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const configured = '{{ ctx.popup.record.staffseq }}';
|
|
63
|
+
const rules = {
|
|
64
|
+
value: [
|
|
65
|
+
{
|
|
66
|
+
key: 'rule',
|
|
67
|
+
title: 'Rule',
|
|
68
|
+
enable: true,
|
|
69
|
+
condition: { logic: '$and', items: [] },
|
|
70
|
+
actions: [
|
|
71
|
+
{
|
|
72
|
+
key: 'assign',
|
|
73
|
+
name: 'linkageAssignField',
|
|
74
|
+
params: {
|
|
75
|
+
value: [
|
|
76
|
+
{
|
|
77
|
+
key: 'field',
|
|
78
|
+
enable: true,
|
|
79
|
+
mode,
|
|
80
|
+
condition: { logic: '$and', items: [] },
|
|
81
|
+
targetPath: 'staffname',
|
|
82
|
+
value: configured,
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
form.setStepParams('eventSettings', 'linkageRules', rules);
|
|
92
|
+
const saved = JSON.parse(JSON.stringify(form.serialize()));
|
|
93
|
+
expect(saved.subModels.grid.stepParams.eventSettings.linkageRules).toEqual(rules);
|
|
94
|
+
expect(saved.stepParams.eventSettings?.linkageRules).toBeUndefined();
|
|
95
|
+
|
|
96
|
+
const token = `test.${Buffer.from(JSON.stringify({ userId: 2, signInTime: 'member-form' })).toString('base64url')}.sig`;
|
|
97
|
+
type Item = { id: string; rd: string; template: unknown; contextParams: unknown };
|
|
98
|
+
let resolveResponse: (() => void) | undefined;
|
|
99
|
+
const responseReady = new Promise<void>((resolve) => {
|
|
100
|
+
resolveResponse = resolve;
|
|
101
|
+
});
|
|
102
|
+
const request = vi.fn(async ({ data }: { data: { values: { batch: Item[] } } }) => {
|
|
103
|
+
const batch = data.values.batch;
|
|
104
|
+
expect(batch[0].rd).toBe(generateFlowModelRdFromToken(form.uid, token));
|
|
105
|
+
expect(batch[0].contextParams).toEqual({
|
|
106
|
+
'popup.record': { collection: 't1_user', dataSourceKey: 'main', filterByTk: '1' },
|
|
107
|
+
});
|
|
108
|
+
await responseReady;
|
|
109
|
+
return {
|
|
110
|
+
data: {
|
|
111
|
+
data: {
|
|
112
|
+
results: batch.map((item) => ({
|
|
113
|
+
id: item.id,
|
|
114
|
+
data: JSON.parse(JSON.stringify(item.template).replaceAll(configured, 'STAFF-001')),
|
|
115
|
+
})),
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
engine.context.defineProperty('api', { value: { auth: { token, role: 'member' }, request } });
|
|
121
|
+
form.context.defineProperty('popup', {
|
|
122
|
+
value: { record: { id: 1 } },
|
|
123
|
+
resolveOnServer: true,
|
|
124
|
+
meta: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
buildVariablesParams: () => ({ record: { collection: 't1_user', dataSourceKey: 'main', filterByTk: '1' } }),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const resource = form.resource as SingleRecordResource;
|
|
130
|
+
resource.setData(use === 'EditFormModel' ? { id: 1, staffname: null } : {});
|
|
131
|
+
function View() {
|
|
132
|
+
form.useHooksBeforeRender();
|
|
133
|
+
return (
|
|
134
|
+
<FormComponent model={form}>
|
|
135
|
+
<Form.Item name="staffname">
|
|
136
|
+
<Input aria-label="staffname" />
|
|
137
|
+
</Form.Item>
|
|
138
|
+
</FormComponent>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const dispatchEvent = vi.spyOn(form, 'dispatchEvent');
|
|
142
|
+
const view = render(<View />);
|
|
143
|
+
try {
|
|
144
|
+
form.formValueRuntime?.mount({ sync: true });
|
|
145
|
+
// Simulate the field mounting, without involving the page layout and field-renderer plugins.
|
|
146
|
+
engine.emitter.emit('model:mounted', { model: form.subModels.grid.subModels.items[0] });
|
|
147
|
+
let pending: Promise<unknown> | undefined;
|
|
148
|
+
await act(async () => {
|
|
149
|
+
pending = form.applyFlow('eventSettings');
|
|
150
|
+
});
|
|
151
|
+
await waitFor(() => expect(request).toHaveBeenCalled());
|
|
152
|
+
await act(async () => {
|
|
153
|
+
resolveResponse?.();
|
|
154
|
+
await pending;
|
|
155
|
+
});
|
|
156
|
+
if (mode === 'default') {
|
|
157
|
+
// Resolving a default value must not overwrite an existing edit record.
|
|
158
|
+
expect(form.subModels.grid.subModels.items[0].props.initialValue).toBe('STAFF-001');
|
|
159
|
+
}
|
|
160
|
+
await waitFor(() => expect((screen.getByLabelText('staffname') as HTMLInputElement).value).toBe(expected));
|
|
161
|
+
expect(form.form.getFieldValue('staffname')).toBe(expected || null);
|
|
162
|
+
} finally {
|
|
163
|
+
resolveResponse?.();
|
|
164
|
+
await act(async () => {
|
|
165
|
+
view.unmount();
|
|
166
|
+
form.formValueRuntime?.dispose();
|
|
167
|
+
// Drain automatic linkage refreshes before another engine starts its variable batch.
|
|
168
|
+
await Promise.all(
|
|
169
|
+
dispatchEvent.mock.results.filter((result) => result.type === 'return').map((result) => result.value),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
dispatchEvent.mockRestore();
|
|
173
|
+
engine.disposeScheduler();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
@@ -152,6 +152,7 @@ const JSBlockPlainHost = ({
|
|
|
152
152
|
afterContent,
|
|
153
153
|
contentRef,
|
|
154
154
|
marginBlock,
|
|
155
|
+
minHeight,
|
|
155
156
|
...rest
|
|
156
157
|
}: React.HTMLAttributes<HTMLDivElement> & {
|
|
157
158
|
uid: string;
|
|
@@ -161,6 +162,7 @@ const JSBlockPlainHost = ({
|
|
|
161
162
|
afterContent?: React.ReactNode;
|
|
162
163
|
contentRef: React.RefObject<HTMLDivElement>;
|
|
163
164
|
marginBlock: number;
|
|
165
|
+
minHeight: number;
|
|
164
166
|
}) => {
|
|
165
167
|
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
|
166
168
|
const resolvedHeight = usePlainHostHeight({ height, heightMode, hostRef, marginBlock });
|
|
@@ -175,7 +177,7 @@ const JSBlockPlainHost = ({
|
|
|
175
177
|
display: 'flex',
|
|
176
178
|
flexDirection: 'column',
|
|
177
179
|
height: resolvedHeight ?? undefined,
|
|
178
|
-
minHeight
|
|
180
|
+
minHeight,
|
|
179
181
|
overflow: 'auto',
|
|
180
182
|
...(style || {}),
|
|
181
183
|
}}
|
|
@@ -228,6 +230,8 @@ export class JSBlockModel extends BlockModel {
|
|
|
228
230
|
afterContent={afterContent}
|
|
229
231
|
contentRef={this.context.ref}
|
|
230
232
|
marginBlock={this.context.themeToken?.marginBlock ?? 0}
|
|
233
|
+
// Keep empty blocks reachable by the settings overlay while the UI editor is active.
|
|
234
|
+
minHeight={this.context.flowSettingsEnabled ? this.context.themeToken?.controlHeightLG ?? 40 : 0}
|
|
231
235
|
/>
|
|
232
236
|
);
|
|
233
237
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React from 'react';
|
|
11
11
|
import { App, ConfigProvider } from 'antd';
|
|
12
|
-
import { render } from '@nocobase/test/client';
|
|
12
|
+
import { act, render, waitFor } from '@nocobase/test/client';
|
|
13
13
|
import { describe, expect, it, vi } from 'vitest';
|
|
14
14
|
import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
|
|
15
15
|
import { JSBlockModel } from '../JSBlock';
|
|
@@ -78,6 +78,28 @@ describe('JSBlockModel', () => {
|
|
|
78
78
|
expect(model.context.ref.current).toBe(host?.firstElementChild);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
it('keeps an empty cardless block reachable only while the UI editor is active', async () => {
|
|
82
|
+
const { engine, model } = createJSBlock('js-block-empty-without-card', false, { style: undefined });
|
|
83
|
+
await engine.flowSettings.disable();
|
|
84
|
+
const { container } = renderBlock(engine, model);
|
|
85
|
+
const host = container.querySelector('#model-js-block-empty-without-card') as HTMLElement;
|
|
86
|
+
const content = model.context.ref.current;
|
|
87
|
+
|
|
88
|
+
expect(host.style.minHeight).toBe('0');
|
|
89
|
+
await act(async () => {
|
|
90
|
+
await engine.flowSettings.enable();
|
|
91
|
+
});
|
|
92
|
+
await waitFor(() => expect(host.style.minHeight).toBe('40px'));
|
|
93
|
+
expect(model.context.ref.current).toBe(content);
|
|
94
|
+
expect(container.querySelector('.ant-card')).toBeNull();
|
|
95
|
+
|
|
96
|
+
await act(async () => {
|
|
97
|
+
await engine.flowSettings.disable();
|
|
98
|
+
});
|
|
99
|
+
await waitFor(() => expect(host.style.minHeight).toBe('0'));
|
|
100
|
+
expect(model.context.ref.current).toBe(content);
|
|
101
|
+
});
|
|
102
|
+
|
|
81
103
|
it('keeps cardless specified-height content scrollable inside the plain host', () => {
|
|
82
104
|
const { engine, model } = createJSBlock('js-block-without-card-height', false, {
|
|
83
105
|
heightMode: 'specifyValue',
|
|
@@ -37,6 +37,7 @@ import { getRowKey } from './utils';
|
|
|
37
37
|
import { getSavedAssociationTitleField, getTableColumnSortField } from './sortUtils';
|
|
38
38
|
import { getFieldBindingUse, rebuildFieldSubModel } from '../../../internal/utils/rebuildFieldSubModel';
|
|
39
39
|
import { getSavedDateTimeFormatParams, resolveDateTimeDisplayProps } from '../../../utils/dateTimeDisplayProps';
|
|
40
|
+
import { QuickEditDataScopeInput, resolveQuickEditDataScopeStep } from '../../../internal/utils/quickEditDataScope';
|
|
40
41
|
|
|
41
42
|
export function FieldDeletePlaceholder(props: any) {
|
|
42
43
|
const { t } = useTranslation();
|
|
@@ -542,6 +543,36 @@ TableColumnModel.registerFlow({
|
|
|
542
543
|
ctx.model.setProps('editable', ctx.model.associationPathName ? false : params.editable);
|
|
543
544
|
},
|
|
544
545
|
},
|
|
546
|
+
quickEditDataScope: {
|
|
547
|
+
title: tExpr('Data scope'),
|
|
548
|
+
uiMode: {
|
|
549
|
+
type: 'dialog',
|
|
550
|
+
props: {
|
|
551
|
+
width: 800,
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
uiSchema: {
|
|
555
|
+
filter: {
|
|
556
|
+
type: 'object',
|
|
557
|
+
'x-decorator': 'FormItem',
|
|
558
|
+
'x-component': QuickEditDataScopeInput,
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
useRawParams: true,
|
|
562
|
+
defaultParams: {
|
|
563
|
+
filter: { logic: '$and', items: [] },
|
|
564
|
+
},
|
|
565
|
+
hideInSettings(ctx) {
|
|
566
|
+
if (ctx.model.associationPathName || !ctx.model.props.editable) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
return !resolveQuickEditDataScopeStep(ctx, ctx.collectionField);
|
|
570
|
+
},
|
|
571
|
+
handler() {
|
|
572
|
+
// Settings-only step. The filter is kept raw on the column and handed to the editor model that
|
|
573
|
+
// QuickEditFormModel builds when the popover opens, so there is nothing to apply to the column itself.
|
|
574
|
+
},
|
|
575
|
+
},
|
|
545
576
|
model: {
|
|
546
577
|
title: tExpr('Field component'),
|
|
547
578
|
use: 'displayFieldComponent',
|
|
@@ -9,8 +9,36 @@
|
|
|
9
9
|
|
|
10
10
|
import { FlowEngine } from '@nocobase/flow-engine';
|
|
11
11
|
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
// Prime the model module graph so the editable field bindings used by the quick edit data scope setting are registered.
|
|
13
|
+
import '../../../index';
|
|
14
|
+
import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel';
|
|
12
15
|
import { TableColumnModel } from '../TableColumnModel';
|
|
13
16
|
|
|
17
|
+
const createQuickEditDataScopeCtx = (
|
|
18
|
+
overrides: { associationPathName?: string; editable?: boolean; isAssociation?: boolean } = {},
|
|
19
|
+
) => {
|
|
20
|
+
const engine = new FlowEngine();
|
|
21
|
+
engine.registerModels({ RecordSelectFieldModel });
|
|
22
|
+
const { associationPathName, editable = true, isAssociation = true } = overrides;
|
|
23
|
+
return {
|
|
24
|
+
engine,
|
|
25
|
+
model: {
|
|
26
|
+
associationPathName,
|
|
27
|
+
props: { editable },
|
|
28
|
+
},
|
|
29
|
+
collectionField: {
|
|
30
|
+
interface: isAssociation ? 'm2o' : 'input',
|
|
31
|
+
isAssociationField: () => isAssociation,
|
|
32
|
+
targetCollection: isAssociation ? { template: 'general' } : undefined,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const getQuickEditDataScopeStep = (engine: FlowEngine) => {
|
|
38
|
+
const model = new TableColumnModel({ uid: 'table-column-quick-edit-data-scope', flowEngine: engine } as any);
|
|
39
|
+
return model.getFlow('tableColumnSettings')?.steps?.quickEditDataScope as any;
|
|
40
|
+
};
|
|
41
|
+
|
|
14
42
|
describe('TableColumnModel sorter settings', () => {
|
|
15
43
|
it('clamps custom column width to the minimum value', () => {
|
|
16
44
|
const engine = new FlowEngine();
|
|
@@ -30,6 +58,42 @@ describe('TableColumnModel sorter settings', () => {
|
|
|
30
58
|
expect(setProps).toHaveBeenCalledWith('width', 10);
|
|
31
59
|
});
|
|
32
60
|
|
|
61
|
+
it('offers the quick edit data scope on an editable association column', async () => {
|
|
62
|
+
const engine = new FlowEngine();
|
|
63
|
+
const step = getQuickEditDataScopeStep(engine);
|
|
64
|
+
|
|
65
|
+
const hidden = await step.hideInSettings(createQuickEditDataScopeCtx());
|
|
66
|
+
|
|
67
|
+
expect(hidden).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('hides the quick edit data scope when quick edit is off', async () => {
|
|
71
|
+
const engine = new FlowEngine();
|
|
72
|
+
const step = getQuickEditDataScopeStep(engine);
|
|
73
|
+
|
|
74
|
+
const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ editable: false }));
|
|
75
|
+
|
|
76
|
+
expect(hidden).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('hides the quick edit data scope for non-association columns', async () => {
|
|
80
|
+
const engine = new FlowEngine();
|
|
81
|
+
const step = getQuickEditDataScopeStep(engine);
|
|
82
|
+
|
|
83
|
+
const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ isAssociation: false }));
|
|
84
|
+
|
|
85
|
+
expect(hidden).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('hides the quick edit data scope for relation path columns', async () => {
|
|
89
|
+
const engine = new FlowEngine();
|
|
90
|
+
const step = getQuickEditDataScopeStep(engine);
|
|
91
|
+
|
|
92
|
+
const hidden = await step.hideInSettings(createQuickEditDataScopeCtx({ associationPathName: 'department' }));
|
|
93
|
+
|
|
94
|
+
expect(hidden).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
33
97
|
it('hides quick edit setting for relation path columns added from association groups', async () => {
|
|
34
98
|
const engine = new FlowEngine();
|
|
35
99
|
const model = new TableColumnModel({ uid: 'table-column-relation-path-quick-edit', flowEngine: engine } as any);
|