@nocobase/client-v2 2.1.0-alpha.34 → 2.1.0-alpha.35
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/BaseApplication.d.ts +1 -0
- package/es/PluginManager.d.ts +1 -0
- package/es/components/form/DrawerFormLayout.d.ts +49 -0
- package/es/components/form/EnvVariableInput.d.ts +42 -0
- package/es/components/form/FileSizeInput.d.ts +27 -0
- package/es/components/form/createFormRegistry.d.ts +33 -0
- package/es/components/form/index.d.ts +13 -0
- package/es/components/index.d.ts +1 -1
- package/es/flow/components/FieldAssignRulesEditor.d.ts +1 -0
- package/es/flow/internal/utils/enumOptionsUtils.d.ts +5 -0
- package/es/flow/models/actions/AssociationActionUtils.d.ts +5 -0
- package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +4 -0
- package/es/flow/models/blocks/filter-manager/FilterManager.d.ts +5 -1
- package/es/flow/models/blocks/shared/legacyDefaultValueMigrationBase.d.ts +1 -0
- package/es/flow/models/blocks/table/TableSelectModel.d.ts +8 -0
- package/es/flow/models/fields/DisplayEnumFieldModel.d.ts +6 -0
- package/es/flow/models/fields/DisplayTitleFieldModel.d.ts +1 -1
- package/es/flow/models/utils/displayValueUtils.d.ts +12 -0
- package/es/flow-compat/passwordUtils.d.ts +1 -1
- package/es/index.mjs +117 -106
- package/es/utils/remotePlugins.d.ts +0 -4
- package/lib/index.js +122 -111
- package/package.json +8 -5
- package/src/BaseApplication.tsx +14 -8
- package/src/PluginManager.ts +1 -0
- package/src/__tests__/app.test.tsx +28 -1
- package/src/__tests__/remotePlugins.test.ts +29 -18
- package/src/components/form/DrawerFormLayout.tsx +103 -0
- package/src/components/form/EnvVariableInput.tsx +126 -0
- package/src/components/form/FileSizeInput.tsx +105 -0
- package/src/components/form/createFormRegistry.ts +60 -0
- package/src/components/form/index.tsx +14 -0
- package/src/components/index.ts +1 -1
- package/src/flow/actions/__tests__/dataScopeFilter.test.ts +92 -13
- package/src/flow/actions/__tests__/formAssignRules.legacyMigration.test.tsx +173 -0
- package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +476 -1
- package/src/flow/actions/filterFormDefaultValues.tsx +30 -9
- package/src/flow/actions/formAssignRules.tsx +24 -9
- package/src/flow/actions/linkageRules.tsx +240 -258
- package/src/flow/actions/setTargetDataScope.tsx +32 -3
- package/src/flow/actions/titleField.tsx +1 -1
- package/src/flow/actions/validation.tsx +1 -1
- package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +0 -4
- package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +13 -5
- package/src/flow/components/FieldAssignRulesEditor.tsx +2 -0
- package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +81 -4
- package/src/flow/components/filter/LinkageFilterItem.tsx +9 -2
- package/src/flow/components/filter/VariableFilterItem.tsx +2 -6
- package/src/flow/components/filter/__tests__/LinkageFilterItem.test.tsx +71 -0
- package/src/flow/components/filter/__tests__/VariableFilterItem.test.tsx +48 -0
- package/src/flow/internal/utils/__tests__/enumOptionsUtils.test.ts +10 -1
- package/src/flow/internal/utils/enumOptionsUtils.ts +29 -0
- package/src/flow/models/actions/AssociateActionModel.tsx +2 -2
- package/src/flow/models/actions/AssociationActionUtils.ts +14 -0
- package/src/flow/models/actions/__tests__/AssociationActionModel.test.ts +63 -0
- package/src/flow/models/base/CollectionBlockModel.tsx +7 -0
- package/src/flow/models/base/PageModel/RootPageModel.tsx +1 -1
- package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +33 -9
- package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +53 -13
- package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +63 -3
- package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +33 -1
- package/src/flow/models/blocks/filter-form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/filter-manager/FilterManager.ts +66 -2
- package/src/flow/models/blocks/filter-manager/__tests__/FilterManager.test.ts +270 -0
- package/src/flow/models/blocks/form/FormBlockModel.tsx +8 -5
- package/src/flow/models/blocks/form/FormItemModel.tsx +1 -1
- package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +30 -0
- package/src/flow/models/blocks/form/__tests__/legacyDefaultValueMigration.test.ts +3 -0
- package/src/flow/models/blocks/form/value-runtime/rules.ts +6 -1
- package/src/flow/models/blocks/form/value-runtime/runtime.ts +6 -1
- package/src/flow/models/blocks/shared/legacyDefaultValueMigrationBase.ts +21 -5
- package/src/flow/models/blocks/table/TableBlockModel.tsx +11 -6
- package/src/flow/models/blocks/table/TableColumnModel.tsx +8 -2
- package/src/flow/models/blocks/table/TableSelectModel.tsx +36 -26
- package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowClick.test.ts +69 -0
- package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +132 -1
- package/src/flow/models/blocks/table/__tests__/TableSelectModel.test.ts +41 -0
- package/src/flow/models/fields/ClickableFieldModel.tsx +9 -4
- package/src/flow/models/fields/DisplayEnumFieldModel.tsx +44 -0
- package/src/flow/models/fields/DisplayTitleFieldModel.tsx +12 -4
- package/src/flow/models/fields/SelectFieldModel.tsx +31 -1
- package/src/flow/models/fields/__tests__/ClickableFieldModel.test.ts +23 -0
- package/src/flow/models/fields/__tests__/DisplayEnumFieldModel.test.tsx +39 -0
- package/src/flow/models/fields/mobile-components/MobileSelect.tsx +2 -1
- package/src/flow/models/fields/mobile-components/__tests__/MobileSelect.test.tsx +7 -0
- package/src/flow/models/utils/displayValueUtils.ts +57 -0
- package/src/utils/globalDeps.ts +8 -0
- package/src/utils/remotePlugins.ts +7 -27
|
@@ -311,7 +311,7 @@ describe('AdminLayoutModel menu items', () => {
|
|
|
311
311
|
expect(route.children[1]._model).toBe(adminLayoutModel.subModels.menuItems?.[1]);
|
|
312
312
|
});
|
|
313
313
|
|
|
314
|
-
it('should filter legacy page menu routes in v2 admin layout', () => {
|
|
314
|
+
it('should filter legacy page menu routes but keep empty groups in v2 admin layout', () => {
|
|
315
315
|
const adminLayoutModel = engine.createModel<AdminLayoutModel>({
|
|
316
316
|
uid: 'admin-layout-model',
|
|
317
317
|
use: AdminLayoutModel,
|
|
@@ -369,22 +369,30 @@ describe('AdminLayoutModel menu items', () => {
|
|
|
369
369
|
t: (title) => title,
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
-
expect(route.children).toHaveLength(
|
|
372
|
+
expect(route.children).toHaveLength(3);
|
|
373
373
|
expect(route.children[0]).toMatchObject({
|
|
374
|
+
path: '/admin/2',
|
|
375
|
+
redirect: '/admin/2',
|
|
376
|
+
_runtimePath: null,
|
|
377
|
+
_navigationMode: 'spa',
|
|
378
|
+
_isLegacy: false,
|
|
379
|
+
});
|
|
380
|
+
expect(route.children[0].routes).toBeUndefined();
|
|
381
|
+
expect(route.children[1]).toMatchObject({
|
|
374
382
|
path: '/admin/3',
|
|
375
383
|
redirect: '/admin/nested-flow-page',
|
|
376
384
|
_runtimePath: '/apps/demo/v2/admin/nested-flow-page',
|
|
377
385
|
_navigationMode: 'spa',
|
|
378
386
|
_isLegacy: false,
|
|
379
387
|
});
|
|
380
|
-
expect(route.children[
|
|
381
|
-
expect(route.children[
|
|
388
|
+
expect(route.children[1].routes).toHaveLength(1);
|
|
389
|
+
expect(route.children[1].routes?.[0]).toMatchObject({
|
|
382
390
|
path: '/admin/nested-flow-page',
|
|
383
391
|
_runtimePath: '/apps/demo/v2/admin/nested-flow-page',
|
|
384
392
|
_navigationMode: 'spa',
|
|
385
393
|
_isLegacy: false,
|
|
386
394
|
});
|
|
387
|
-
expect(route.children[
|
|
395
|
+
expect(route.children[2]).toMatchObject({
|
|
388
396
|
path: '/admin/__admin_layout__/link/4',
|
|
389
397
|
});
|
|
390
398
|
});
|
|
@@ -35,6 +35,7 @@ type CollectionFieldLike = {
|
|
|
35
35
|
title?: unknown;
|
|
36
36
|
type?: unknown;
|
|
37
37
|
interface?: unknown;
|
|
38
|
+
uiSchema?: unknown;
|
|
38
39
|
targetKey?: unknown;
|
|
39
40
|
targetCollectionTitleFieldName?: unknown;
|
|
40
41
|
targetCollection?: any;
|
|
@@ -194,6 +195,7 @@ export const FieldAssignRulesEditor: React.FC<FieldAssignRulesEditorProps> = (pr
|
|
|
194
195
|
title,
|
|
195
196
|
type: String(f.type || 'string'),
|
|
196
197
|
interface: fieldInterface,
|
|
198
|
+
uiSchema: (f as any).uiSchema,
|
|
197
199
|
paths: [...basePaths, name],
|
|
198
200
|
};
|
|
199
201
|
|
|
@@ -15,7 +15,7 @@ import type { MetaTreeNode } from '@nocobase/flow-engine';
|
|
|
15
15
|
import { FieldAssignRulesEditor, type FieldAssignRuleItem } from '../FieldAssignRulesEditor';
|
|
16
16
|
import { mergeItemMetaTreeForAssignValue } from '../FieldAssignValueInput';
|
|
17
17
|
|
|
18
|
-
const { mockFieldAssignValueInput } = vi.hoisted(() => ({
|
|
18
|
+
const { mockFieldAssignValueInput, mockConditionBuilder } = vi.hoisted(() => ({
|
|
19
19
|
mockFieldAssignValueInput: vi.fn((props: any) => (
|
|
20
20
|
<div
|
|
21
21
|
data-testid="mock-value-input"
|
|
@@ -25,6 +25,9 @@ const { mockFieldAssignValueInput } = vi.hoisted(() => ({
|
|
|
25
25
|
data-date-constant={props?.enableDateVariableAsConstant ? 'yes' : 'no'}
|
|
26
26
|
/>
|
|
27
27
|
)),
|
|
28
|
+
mockConditionBuilder: vi.fn((props: any) => (
|
|
29
|
+
<div data-testid="mock-condition-builder" data-extra={props?.extraMetaTree ? 'yes' : 'no'} />
|
|
30
|
+
)),
|
|
28
31
|
}));
|
|
29
32
|
|
|
30
33
|
vi.mock('../FieldAssignValueInput', async () => {
|
|
@@ -39,9 +42,7 @@ vi.mock('../ConditionBuilder', async () => {
|
|
|
39
42
|
const actual = await vi.importActual<typeof import('../ConditionBuilder')>('../ConditionBuilder');
|
|
40
43
|
return {
|
|
41
44
|
...actual,
|
|
42
|
-
ConditionBuilder:
|
|
43
|
-
<div data-testid="mock-condition-builder" data-extra={props?.extraMetaTree ? 'yes' : 'no'} />
|
|
44
|
-
),
|
|
45
|
+
ConditionBuilder: mockConditionBuilder,
|
|
45
46
|
};
|
|
46
47
|
});
|
|
47
48
|
|
|
@@ -55,6 +56,7 @@ describe('FieldAssignRulesEditor', () => {
|
|
|
55
56
|
|
|
56
57
|
beforeEach(() => {
|
|
57
58
|
mockFieldAssignValueInput.mockClear();
|
|
59
|
+
mockConditionBuilder.mockClear();
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
const createAssociationFixture = () => {
|
|
@@ -574,6 +576,81 @@ describe('FieldAssignRulesEditor', () => {
|
|
|
574
576
|
expect(getByTestId('mock-condition-builder').getAttribute('data-extra')).toBe('yes');
|
|
575
577
|
});
|
|
576
578
|
|
|
579
|
+
it('preserves uiSchema enum for current item attributes in condition extra tree', async () => {
|
|
580
|
+
const aaaField: any = {
|
|
581
|
+
name: 'AAA',
|
|
582
|
+
title: 'AAA',
|
|
583
|
+
type: 'array',
|
|
584
|
+
interface: 'multipleSelect',
|
|
585
|
+
isAssociationField: () => false,
|
|
586
|
+
uiSchema: {
|
|
587
|
+
enum: [
|
|
588
|
+
{ label: 'Test1', value: 'Test1' },
|
|
589
|
+
{ label: 'Test2', value: 'Test2' },
|
|
590
|
+
],
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
const roleNameField: any = {
|
|
594
|
+
name: 'name',
|
|
595
|
+
title: 'Role name',
|
|
596
|
+
type: 'string',
|
|
597
|
+
interface: 'input',
|
|
598
|
+
isAssociationField: () => false,
|
|
599
|
+
};
|
|
600
|
+
const rolesCollection = {
|
|
601
|
+
getField: (name: string) => (name === 'AAA' ? aaaField : name === 'name' ? roleNameField : null),
|
|
602
|
+
getFields: () => [roleNameField, aaaField],
|
|
603
|
+
};
|
|
604
|
+
const rolesField: any = {
|
|
605
|
+
name: 'roles',
|
|
606
|
+
title: 'Roles',
|
|
607
|
+
type: 'belongsToMany',
|
|
608
|
+
interface: 'm2m',
|
|
609
|
+
isAssociationField: () => true,
|
|
610
|
+
targetCollection: rolesCollection,
|
|
611
|
+
};
|
|
612
|
+
const rootCollection = {
|
|
613
|
+
getField: (name: string) => (name === 'roles' ? rolesField : null),
|
|
614
|
+
getFields: () => [rolesField],
|
|
615
|
+
};
|
|
616
|
+
const value: FieldAssignRuleItem[] = [
|
|
617
|
+
{
|
|
618
|
+
key: 'rule-roles-name',
|
|
619
|
+
enable: true,
|
|
620
|
+
targetPath: 'roles.name',
|
|
621
|
+
mode: 'assign',
|
|
622
|
+
condition: { logic: '$and', items: [] },
|
|
623
|
+
},
|
|
624
|
+
];
|
|
625
|
+
|
|
626
|
+
render(
|
|
627
|
+
wrap(
|
|
628
|
+
<FieldAssignRulesEditor t={t} fieldOptions={[]} rootCollection={rootCollection} value={value} showCondition />,
|
|
629
|
+
),
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
const conditionProps = mockConditionBuilder.mock.calls[mockConditionBuilder.mock.calls.length - 1]?.[0];
|
|
633
|
+
const extraMetaTree = conditionProps?.extraMetaTree as MetaTreeNode[] | undefined;
|
|
634
|
+
expect(Array.isArray(extraMetaTree)).toBe(true);
|
|
635
|
+
|
|
636
|
+
const itemNode = extraMetaTree?.find((node) => node.name === 'item') as MetaTreeNode | undefined;
|
|
637
|
+
expect(itemNode).toBeTruthy();
|
|
638
|
+
|
|
639
|
+
const itemChildren = (Array.isArray(itemNode?.children) ? itemNode.children : []) as MetaTreeNode[];
|
|
640
|
+
const attributesNode = itemChildren.find((node) => node.name === 'value') as MetaTreeNode | undefined;
|
|
641
|
+
expect(attributesNode).toBeTruthy();
|
|
642
|
+
|
|
643
|
+
const attributeChildren =
|
|
644
|
+
typeof attributesNode?.children === 'function' ? await attributesNode.children() : attributesNode?.children ?? [];
|
|
645
|
+
const aaaNode = (attributeChildren as MetaTreeNode[]).find((node) => node.name === 'AAA') as any;
|
|
646
|
+
|
|
647
|
+
expect(aaaNode?.interface).toBe('multipleSelect');
|
|
648
|
+
expect(aaaNode?.uiSchema?.enum).toEqual([
|
|
649
|
+
{ label: 'Test1', value: 'Test1' },
|
|
650
|
+
{ label: 'Test2', value: 'Test2' },
|
|
651
|
+
]);
|
|
652
|
+
});
|
|
653
|
+
|
|
577
654
|
it('renders empty state when no items', () => {
|
|
578
655
|
const { container } = render(
|
|
579
656
|
wrap(<FieldAssignRulesEditor t={t} fieldOptions={[]} value={[]} showCondition={false} />),
|
|
@@ -22,7 +22,12 @@ import {
|
|
|
22
22
|
observer,
|
|
23
23
|
} from '@nocobase/flow-engine';
|
|
24
24
|
import { NumberPicker } from '@formily/antd-v5';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
enumToOptions,
|
|
27
|
+
normalizeSelectRenderValue,
|
|
28
|
+
translateOptions,
|
|
29
|
+
UiSchemaEnumItem,
|
|
30
|
+
} from '../../internal/utils/enumOptionsUtils';
|
|
26
31
|
import { mergeItemMetaTreeForAssignValue } from '../FieldAssignValueInput';
|
|
27
32
|
import { resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
|
|
28
33
|
|
|
@@ -82,7 +87,9 @@ function createStaticInputRenderer(
|
|
|
82
87
|
} else if (optionsFromEnum) {
|
|
83
88
|
finalProps = { ...finalProps, options: optionsFromEnum };
|
|
84
89
|
}
|
|
85
|
-
return
|
|
90
|
+
return (
|
|
91
|
+
<Select {...finalProps} {...rest} value={normalizeSelectRenderValue(value, finalProps)} onChange={onChange} />
|
|
92
|
+
);
|
|
86
93
|
}
|
|
87
94
|
if (xComponent === 'DateFilterDynamicComponent')
|
|
88
95
|
return <DateFilterDynamicComponentLazy {...commonProps} {...rest} value={value} onChange={onChange} />;
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from '@nocobase/flow-engine';
|
|
26
26
|
import _ from 'lodash';
|
|
27
27
|
import { NumberPicker } from '@formily/antd-v5';
|
|
28
|
-
import { enumToOptions, UiSchemaEnumItem } from '../../internal/utils/enumOptionsUtils';
|
|
28
|
+
import { enumToOptions, normalizeSelectRenderValue, UiSchemaEnumItem } from '../../internal/utils/enumOptionsUtils';
|
|
29
29
|
import { resolveOperatorComponent } from '../../internal/utils/operatorSchemaHelper';
|
|
30
30
|
|
|
31
31
|
const { DateFilterDynamicComponent: DateFilterDynamicComponentLazy } = lazy(
|
|
@@ -155,11 +155,7 @@ function createStaticInputRenderer(
|
|
|
155
155
|
options={selectOptions}
|
|
156
156
|
{...commonProps}
|
|
157
157
|
{...rest}
|
|
158
|
-
value={
|
|
159
|
-
Array.isArray(value) || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
160
|
-
? (value as unknown)
|
|
161
|
-
: undefined
|
|
162
|
-
}
|
|
158
|
+
value={normalizeSelectRenderValue(value, commonProps) as any}
|
|
163
159
|
onChange={(v) => onChange?.(v as unknown as VariableFilterItemValue['value'])}
|
|
164
160
|
/>
|
|
165
161
|
);
|
|
@@ -67,6 +67,9 @@ function createModel() {
|
|
|
67
67
|
return { model, app };
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const getRenderedSelectTexts = (container: HTMLElement) =>
|
|
71
|
+
Array.from(container.querySelectorAll('.ant-select-selection-item')).map((node) => (node.textContent || '').trim());
|
|
72
|
+
|
|
70
73
|
describe('LinkageFilterItem', () => {
|
|
71
74
|
beforeEach(() => {
|
|
72
75
|
document.body.innerHTML = '';
|
|
@@ -201,6 +204,74 @@ describe('LinkageFilterItem', () => {
|
|
|
201
204
|
});
|
|
202
205
|
});
|
|
203
206
|
|
|
207
|
+
it('does not render an empty selected option for constant single select', async () => {
|
|
208
|
+
const value = observable({ path: '', operator: '', value: '' }) as any;
|
|
209
|
+
const { model } = createModel();
|
|
210
|
+
|
|
211
|
+
(globalThis as any).__TEST_META__ = {
|
|
212
|
+
interface: 'select',
|
|
213
|
+
uiSchema: {
|
|
214
|
+
'x-component': 'Select',
|
|
215
|
+
enum: [{ label: 'Published', value: 'published' }],
|
|
216
|
+
'x-filter-operators': [
|
|
217
|
+
{
|
|
218
|
+
value: '$eq',
|
|
219
|
+
label: 'is',
|
|
220
|
+
selected: true,
|
|
221
|
+
schema: { 'x-component': 'Select' },
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
paths: ['collection', 'status'],
|
|
226
|
+
name: 'status',
|
|
227
|
+
title: 'Status',
|
|
228
|
+
type: 'string',
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const view = render(<LinkageFilterItem value={value} model={model} />);
|
|
232
|
+
fireEvent.click(screen.getByTestId('variable-input'));
|
|
233
|
+
|
|
234
|
+
await waitFor(() => {
|
|
235
|
+
expect(value.operator).toBe('$eq');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(getRenderedSelectTexts(view.container).filter((text) => text === '')).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('does not render an empty selected tag for constant multi select', async () => {
|
|
242
|
+
const value = observable({ path: '', operator: '', value: '' }) as any;
|
|
243
|
+
const { model } = createModel();
|
|
244
|
+
|
|
245
|
+
(globalThis as any).__TEST_META__ = {
|
|
246
|
+
interface: 'select',
|
|
247
|
+
uiSchema: {
|
|
248
|
+
'x-component': 'Select',
|
|
249
|
+
enum: [{ label: 'Published', value: 'published' }],
|
|
250
|
+
'x-filter-operators': [
|
|
251
|
+
{
|
|
252
|
+
value: '$in',
|
|
253
|
+
label: 'is any of',
|
|
254
|
+
selected: true,
|
|
255
|
+
schema: { 'x-component': 'Select', 'x-component-props': { mode: 'tags' } },
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
paths: ['collection', 'status'],
|
|
260
|
+
name: 'status',
|
|
261
|
+
title: 'Status',
|
|
262
|
+
type: 'string',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const view = render(<LinkageFilterItem value={value} model={model} />);
|
|
266
|
+
fireEvent.click(screen.getByTestId('variable-input'));
|
|
267
|
+
|
|
268
|
+
await waitFor(() => {
|
|
269
|
+
expect(value.operator).toBe('$in');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
expect(getRenderedSelectTexts(view.container).filter((text) => text === '')).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
204
275
|
it('passes enum options to single select multi-value operators', async () => {
|
|
205
276
|
const value = observable({ path: '', operator: '', value: ['draft'] }) as any;
|
|
206
277
|
const { model, app } = createModel();
|
|
@@ -47,6 +47,9 @@ vi.mock('@nocobase/flow-engine', async () => {
|
|
|
47
47
|
return { ...actual, VariableInput: MockVariableInput };
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
const getRenderedSelectTexts = (root: ParentNode = document.body) =>
|
|
51
|
+
Array.from(root.querySelectorAll('.ant-select-selection-item')).map((node) => (node.textContent || '').trim());
|
|
52
|
+
|
|
50
53
|
function CreateModel() {
|
|
51
54
|
const engine = new FlowEngine();
|
|
52
55
|
const model = new FlowModel({ uid: 'm-variable-filter', flowEngine: engine });
|
|
@@ -262,6 +265,51 @@ describe('VariableFilterItem', () => {
|
|
|
262
265
|
delete (globalThis as any).__TEST_META__;
|
|
263
266
|
});
|
|
264
267
|
|
|
268
|
+
it('does not render an empty selected option for right constant select', async () => {
|
|
269
|
+
const value = observable({ path: '', operator: '', value: '' }) as any;
|
|
270
|
+
const model = CreateModel();
|
|
271
|
+
|
|
272
|
+
const prevMeta = (globalThis as any).__TEST_META__;
|
|
273
|
+
const prevPath = (globalThis as any).__TEST_PATH__;
|
|
274
|
+
(globalThis as any).__TEST_PATH__ = 'status';
|
|
275
|
+
(globalThis as any).__TEST_META__ = {
|
|
276
|
+
interface: 'select',
|
|
277
|
+
uiSchema: {
|
|
278
|
+
'x-component': 'Select',
|
|
279
|
+
enum: [{ label: 'Draft', value: 'draft' }],
|
|
280
|
+
'x-filter-operators': [
|
|
281
|
+
{
|
|
282
|
+
value: '$eq',
|
|
283
|
+
label: 'Equals',
|
|
284
|
+
selected: true,
|
|
285
|
+
schema: { 'x-component': 'Select' },
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
paths: ['collection', 'status'],
|
|
290
|
+
name: 'status',
|
|
291
|
+
title: 'Status',
|
|
292
|
+
type: 'string',
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
render(<VariableFilterItem value={value} model={model} rightAsVariable />);
|
|
296
|
+
fireEvent.click(screen.getAllByTestId('variable-input')[0]);
|
|
297
|
+
|
|
298
|
+
await waitFor(() => {
|
|
299
|
+
expect(value.operator).toBe('$eq');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const rightVariableInputProps = (globalThis as any).__LAST_VARIABLE_INPUT_PROPS__;
|
|
303
|
+
const Renderer = rightVariableInputProps?.converters?.renderInputComponent?.({ paths: ['constant'] });
|
|
304
|
+
expect(Renderer).toBeTruthy();
|
|
305
|
+
|
|
306
|
+
const rendered = render(<Renderer value="" onChange={vi.fn()} />);
|
|
307
|
+
expect(getRenderedSelectTexts(rendered.container).filter((text) => text === '')).toHaveLength(0);
|
|
308
|
+
|
|
309
|
+
(globalThis as any).__TEST_META__ = prevMeta;
|
|
310
|
+
(globalThis as any).__TEST_PATH__ = prevPath;
|
|
311
|
+
});
|
|
312
|
+
|
|
265
313
|
it('renders right VariableInput when rightAsVariable=true and hides it for noValue operator', async () => {
|
|
266
314
|
const value = observable({ path: '', operator: '', value: '' }) as any;
|
|
267
315
|
const model = CreateModel();
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect } from 'vitest';
|
|
11
|
-
import { enumToOptions, translateOptions } from '../enumOptionsUtils';
|
|
11
|
+
import { enumToOptions, getSelectedEnumLabels, translateOptions } from '../enumOptionsUtils';
|
|
12
12
|
|
|
13
13
|
// 一个极简的 t:
|
|
14
14
|
// - 直接返回传入 key;
|
|
@@ -55,4 +55,13 @@ describe('enumOptions utils', () => {
|
|
|
55
55
|
{ label: '否', value: false },
|
|
56
56
|
]);
|
|
57
57
|
});
|
|
58
|
+
|
|
59
|
+
it('getSelectedEnumLabels: keeps label for selected value missing from limited options', () => {
|
|
60
|
+
const labels = getSelectedEnumLabels('published', [
|
|
61
|
+
{ label: 'Draft', value: 'draft' },
|
|
62
|
+
{ label: 'Published', value: 'published' },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
expect(labels).toEqual([{ label: 'Published', value: 'published' }]);
|
|
66
|
+
});
|
|
58
67
|
});
|
|
@@ -46,6 +46,35 @@ export function enumToOptions(uiEnum: UiSchemaEnumItem[] | undefined, t: (s: str
|
|
|
46
46
|
return translateOptions(normalized, t);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function normalizeSelectRenderValue(value: unknown, props?: Record<string, any>) {
|
|
50
|
+
const mode = props?.mode;
|
|
51
|
+
const isMultiple = mode === 'multiple' || mode === 'tags' || props?.multiple === true;
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value.filter((item) => item !== '' && item !== null && typeof item !== 'undefined');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (value === '' || value === null || typeof value === 'undefined') {
|
|
58
|
+
return isMultiple ? [] : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getSelectedEnumLabels(value: any, fallbackOptions: Option[] = []): Array<{ value: any; label: any }> {
|
|
65
|
+
const values = Array.isArray(value) ? value : value == null ? [] : [value];
|
|
66
|
+
return values.map((item) => {
|
|
67
|
+
const fallback = fallbackOptions.find((option) => option?.value === item);
|
|
68
|
+
if (fallback) {
|
|
69
|
+
return { value: item, label: fallback.label };
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
value: item,
|
|
73
|
+
label: item?.toString?.() ?? item,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
49
78
|
// Populate model.props.options from uiSchema.enum if missing; returns whether options were injected
|
|
50
79
|
export function ensureOptionsFromUiSchemaEnumIfAbsent(model: FlowModel, collectionField: CollectionField): boolean {
|
|
51
80
|
const iface = collectionField?.interface;
|
|
@@ -16,6 +16,7 @@ import { SkeletonFallback } from '../../components/SkeletonFallback';
|
|
|
16
16
|
import { ActionModel, ActionSceneEnum } from '../base';
|
|
17
17
|
import {
|
|
18
18
|
applyAssociateAction,
|
|
19
|
+
getAssociationSelectorContextInputArgs,
|
|
19
20
|
getAssociationTargetResourceSettings,
|
|
20
21
|
isAssociationBlockContext,
|
|
21
22
|
} from './AssociationActionUtils';
|
|
@@ -139,7 +140,6 @@ AssociateActionModel.registerFlow({
|
|
|
139
140
|
steps: {
|
|
140
141
|
openSelector: {
|
|
141
142
|
async handler(ctx, params) {
|
|
142
|
-
const blockModel = ctx.blockModel;
|
|
143
143
|
const targetResourceSettings = getAssociationTargetResourceSettings(ctx);
|
|
144
144
|
const openMode = ctx.inputArgs?.isMobileLayout ? 'embed' : ctx.inputArgs?.mode || params?.mode || 'drawer';
|
|
145
145
|
const size = ctx.inputArgs?.size || params?.size || 'medium';
|
|
@@ -168,9 +168,9 @@ AssociateActionModel.registerFlow({
|
|
|
168
168
|
scene: 'select',
|
|
169
169
|
dataSourceKey: targetResourceSettings.dataSourceKey,
|
|
170
170
|
collectionName: targetResourceSettings.collectionName,
|
|
171
|
+
...getAssociationSelectorContextInputArgs(ctx),
|
|
171
172
|
rowSelectionProps: {
|
|
172
173
|
type: 'checkbox',
|
|
173
|
-
defaultSelectedRows: () => blockModel?.resource?.getData?.() || [],
|
|
174
174
|
renderCell: undefined,
|
|
175
175
|
selectedRowKeys: undefined,
|
|
176
176
|
onChange: (_, selectedRows) => {
|
|
@@ -33,6 +33,20 @@ export const getAssociationTargetResourceSettings = (ctx: FlowModelContext | any
|
|
|
33
33
|
};
|
|
34
34
|
};
|
|
35
35
|
|
|
36
|
+
export const getAssociationSelectorContextInputArgs = (ctx: FlowModelContext | any) => {
|
|
37
|
+
const blockModel = ctx?.blockModel || ctx?.model?.context?.blockModel;
|
|
38
|
+
const association = blockModel?.association;
|
|
39
|
+
const resourceSettings = getAssociationBlockResourceSettings(ctx);
|
|
40
|
+
const sourceId = blockModel?.resource?.getSourceId?.() ?? resourceSettings?.sourceId;
|
|
41
|
+
const associatedRecords = blockModel?.resource?.getData?.() || [];
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
collectionField: association,
|
|
45
|
+
sourceId,
|
|
46
|
+
associatedRecords,
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
36
50
|
const callAssociationResourceAction = async (resource: any, action: 'add' | 'remove', values: any[]) => {
|
|
37
51
|
if (typeof resource?.[action] === 'function') {
|
|
38
52
|
return await resource[action]({ values });
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
AssociateActionModel,
|
|
17
17
|
CollectionActionGroupModel,
|
|
18
18
|
DisassociateActionModel,
|
|
19
|
+
getAssociationSelectorContextInputArgs,
|
|
19
20
|
getAssociationTargetResourceSettings,
|
|
20
21
|
PopupActionModel,
|
|
21
22
|
RecordActionGroupModel,
|
|
@@ -87,6 +88,68 @@ describe('association action models', () => {
|
|
|
87
88
|
});
|
|
88
89
|
});
|
|
89
90
|
|
|
91
|
+
it('passes one-to-many association context to selector table blocks', () => {
|
|
92
|
+
const association = {
|
|
93
|
+
name: 'orders',
|
|
94
|
+
interface: 'o2m',
|
|
95
|
+
target: 'orders',
|
|
96
|
+
sourceKey: 'id',
|
|
97
|
+
foreignKey: 'userId',
|
|
98
|
+
};
|
|
99
|
+
const ctx: any = {
|
|
100
|
+
blockModel: {
|
|
101
|
+
association,
|
|
102
|
+
resource: {
|
|
103
|
+
getSourceId: () => 362872646860800,
|
|
104
|
+
},
|
|
105
|
+
getResourceSettingsInitParams: () => ({
|
|
106
|
+
dataSourceKey: 'main',
|
|
107
|
+
collectionName: 'orders',
|
|
108
|
+
associationName: 'users.orders',
|
|
109
|
+
sourceId: '{{ctx.popup.record.id}}',
|
|
110
|
+
}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
|
|
115
|
+
collectionField: association,
|
|
116
|
+
sourceId: 362872646860800,
|
|
117
|
+
associatedRecords: [],
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('passes current associated records to selector table blocks', () => {
|
|
122
|
+
const associatedRecords = [{ id: 11 }, { id: 12 }];
|
|
123
|
+
const association = {
|
|
124
|
+
name: 'tags',
|
|
125
|
+
interface: 'm2m',
|
|
126
|
+
target: 'tags',
|
|
127
|
+
sourceKey: 'id',
|
|
128
|
+
targetKey: 'id',
|
|
129
|
+
};
|
|
130
|
+
const ctx: any = {
|
|
131
|
+
blockModel: {
|
|
132
|
+
association,
|
|
133
|
+
resource: {
|
|
134
|
+
getSourceId: () => 1,
|
|
135
|
+
getData: () => associatedRecords,
|
|
136
|
+
},
|
|
137
|
+
getResourceSettingsInitParams: () => ({
|
|
138
|
+
dataSourceKey: 'main',
|
|
139
|
+
collectionName: 'tags',
|
|
140
|
+
associationName: 'posts.tags',
|
|
141
|
+
sourceId: '{{ctx.popup.record.id}}',
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
expect(getAssociationSelectorContextInputArgs(ctx)).toEqual({
|
|
147
|
+
collectionField: association,
|
|
148
|
+
sourceId: 1,
|
|
149
|
+
associatedRecords,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
90
153
|
it('shows Associate only in collection actions of association blocks', async () => {
|
|
91
154
|
const engine = createEngine();
|
|
92
155
|
|
|
@@ -662,6 +662,9 @@ CollectionBlockModel.registerFlow({
|
|
|
662
662
|
async handler(ctx) {
|
|
663
663
|
const blockModel = ctx.model as CollectionBlockModel;
|
|
664
664
|
const filterManager: FilterManager = ctx.model.context.filterManager;
|
|
665
|
+
const preparedFilterBlocks = filterManager?.prepareFiltersForTarget
|
|
666
|
+
? await filterManager.prepareFiltersForTarget(ctx.model.uid)
|
|
667
|
+
: undefined;
|
|
665
668
|
if (filterManager) {
|
|
666
669
|
filterManager.bindToTarget(ctx.model.uid);
|
|
667
670
|
}
|
|
@@ -679,6 +682,10 @@ CollectionBlockModel.registerFlow({
|
|
|
679
682
|
return;
|
|
680
683
|
}
|
|
681
684
|
|
|
685
|
+
preparedFilterBlocks?.forEach((filterBlockModel: any) => {
|
|
686
|
+
filterBlockModel?.markInitialTargetRefreshHandled?.(ctx.model.uid);
|
|
687
|
+
});
|
|
688
|
+
|
|
682
689
|
if (ctx.model.isManualRefresh) {
|
|
683
690
|
ctx.model.resource.loading = false;
|
|
684
691
|
} else {
|
|
@@ -162,7 +162,7 @@ RootPageModel.registerFlow({
|
|
|
162
162
|
const route = ctx.routeRepository.getRouteBySchemaUid(ctx.model.parentId);
|
|
163
163
|
ctx.model.setProps('routeId', route?.id);
|
|
164
164
|
const routes: NocoBaseDesktopRoute[] = _.castArray(route?.children);
|
|
165
|
-
for (const route of routes.sort((a, b) => a.sort - b.sort)) {
|
|
165
|
+
for (const route of routes.filter(Boolean).sort((a, b) => (a.sort || 0) - (b.sort || 0))) {
|
|
166
166
|
// 过滤掉隐藏的路由
|
|
167
167
|
if (route.hideInMenu) {
|
|
168
168
|
continue;
|
|
@@ -32,7 +32,7 @@ import { BlockSceneEnum } from '../../base/BlockModel';
|
|
|
32
32
|
import { FilterBlockModel } from '../../base/FilterBlockModel';
|
|
33
33
|
import { FormComponent } from '../form/FormBlockModel';
|
|
34
34
|
import { isEmptyValue } from '../form/value-runtime/utils';
|
|
35
|
-
import { FilterManager } from '../filter-manager/FilterManager';
|
|
35
|
+
import { FilterManager, type RefreshTargetsByFilterOptions } from '../filter-manager/FilterManager';
|
|
36
36
|
import { FilterFormItemModel } from './FilterFormItemModel';
|
|
37
37
|
import { clearLegacyDefaultValuesFromFilterFormModel } from './legacyDefaultValueMigration';
|
|
38
38
|
import { findFormItemModelByFieldPath } from '../../../internal/utils/modelUtils';
|
|
@@ -54,6 +54,8 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
54
54
|
autoTriggerFilter = true;
|
|
55
55
|
|
|
56
56
|
private removeTargetBlockListener?: () => void;
|
|
57
|
+
private initialDefaultsPromise?: Promise<void>;
|
|
58
|
+
private initialRefreshHandledTargetIds = new Set<string>();
|
|
57
59
|
|
|
58
60
|
get form() {
|
|
59
61
|
return this.context.form;
|
|
@@ -82,13 +84,13 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
82
84
|
this.context.defineProperty('blockModel', {
|
|
83
85
|
value: this,
|
|
84
86
|
});
|
|
85
|
-
this.context.defineMethod('refreshTargets', async () => {
|
|
87
|
+
this.context.defineMethod('refreshTargets', async (options?: RefreshTargetsByFilterOptions) => {
|
|
86
88
|
const gridModel = this.subModels.grid;
|
|
87
89
|
const fieldModels: FilterFormItemModel[] = gridModel.subModels.items;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const filterIds = fieldModels?.map((fieldModel) => fieldModel?.uid).filter(Boolean);
|
|
91
|
+
if (filterIds?.length) {
|
|
92
|
+
const filterManager: FilterManager = this.context.filterManager;
|
|
93
|
+
await filterManager.refreshTargetsByFilter(filterIds, options);
|
|
92
94
|
}
|
|
93
95
|
});
|
|
94
96
|
}
|
|
@@ -124,9 +126,31 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
private async applyDefaultsAndInitialFilter() {
|
|
127
|
-
await this.
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
const prepared = await this.prepareInitialFilterValues();
|
|
130
|
+
if (prepared) {
|
|
131
|
+
await this.context.refreshTargets?.({ excludeTargetIds: this.initialRefreshHandledTargetIds });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async prepareInitialFilterValues() {
|
|
136
|
+
if (!this.form) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!this.initialDefaultsPromise) {
|
|
141
|
+
this.initialDefaultsPromise = (async () => {
|
|
142
|
+
await this.ensureFilterItemsBeforeRender();
|
|
143
|
+
await this.applyFormDefaultValues();
|
|
144
|
+
})();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await this.initialDefaultsPromise;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
markInitialTargetRefreshHandled(targetId: string) {
|
|
152
|
+
if (!targetId) return;
|
|
153
|
+
this.initialRefreshHandledTargetIds.add(targetId);
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
private async ensureFilterItemsBeforeRender() {
|