From bfbb269091d3b6d1ad58b1184186d9f589defb1f Mon Sep 17 00:00:00 2001 From: gchust Date: Mon, 27 Apr 2026 13:05:27 +0800 Subject: [PATCH] feat: improve association fields and tree block in ui building api (#9247) * fix: record action style * fix: action panel block * fix: association fields * feat: support tree block * feat: support tree filter block * fix: association fields models * fix: test * chore: improve code * chore: improve api * fix: test * fix: test --- ...rfaces.approval-blueprint.contract.test.ts | 16 +- .../flow-surfaces.approval.contract.test.ts | 59 +- .../flow-surfaces.approval.unit.test.ts | 11 +- ...flow-surfaces.association.contract.test.ts | 2 +- .../flow-surfaces.blueprint-contract.test.ts | 197 ++++ .../flow-surfaces.builder.unit.test.ts | 76 +- ...-surfaces.catalog-compose.contract.test.ts | 767 ++++++++++++- ...rfaces.field-binding-registry.unit.test.ts | 42 + .../__tests__/flow-surfaces.swagger.test.ts | 27 +- .../__tests__/flow-surfaces.test-plugins.ts | 1 + .../server/__tests__/flow-surfaces.test.ts | 536 ++++++++- .../server/flow-surfaces/apply/compiler.ts | 4 +- .../server/flow-surfaces/approval/README.md | 14 +- .../flow-surfaces/association-interfaces.ts | 12 + .../flow-surfaces/blueprint/compile-blocks.ts | 62 +- .../flow-surfaces/blueprint/public-types.ts | 11 +- .../src/server/flow-surfaces/builder.ts | 34 +- .../src/server/flow-surfaces/catalog.ts | 149 ++- .../server/flow-surfaces/compose-compiler.ts | 16 + .../server/flow-surfaces/configure-options.ts | 49 +- .../flow-surfaces/default-action-popup.ts | 2 +- .../flow-surfaces/field-binding-registry.ts | 3 + .../server/flow-surfaces/field-semantics.ts | 5 +- .../flow-surfaces/field-type-resolver.ts | 321 ++++++ .../src/server/flow-surfaces/node-use-sets.ts | 1 + .../src/server/flow-surfaces/service-utils.ts | 74 +- .../src/server/flow-surfaces/service.ts | 1013 ++++++++++++++++- .../server/flow-surfaces/support-matrix.ts | 11 + .../src/swagger/flow-surfaces.examples.ts | 33 +- .../src/swagger/flow-surfaces.ts | 80 +- 30 files changed, 3439 insertions(+), 189 deletions(-) create mode 100644 packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/association-interfaces.ts create mode 100644 packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-type-resolver.ts diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval-blueprint.contract.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval-blueprint.contract.test.ts index 2356cd3db9d..5d6522859bf 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval-blueprint.contract.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval-blueprint.contract.test.ts @@ -428,15 +428,9 @@ describe('flowSurfaces approval blueprint API contract', () => { }, }), ); - expect(taskCardManagerCatalog.node.configureOptions.fieldComponent.enum).toEqual([ - 'DisplayTextFieldModel', - 'DisplaySubItemFieldModel', - ]); - expect(taskCardSkillsCatalog.node.configureOptions.fieldComponent.enum).toEqual([ - 'DisplayTextFieldModel', - 'DisplaySubListFieldModel', - 'DisplaySubTableFieldModel', - ]); + expect(taskCardManagerCatalog.node.configureOptions.fieldComponent).toBeUndefined(); + expect(taskCardManagerCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetails']); + expect(taskCardSkillsCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetailsList', 'subTable']); expect( ( @@ -446,7 +440,7 @@ describe('flowSurfaces approval blueprint API contract', () => { uid: taskCardManagerItem.uid, }, changes: { - fieldComponent: 'DisplaySubItemFieldModel', + fieldType: 'subDetails', }, }, }) @@ -460,7 +454,7 @@ describe('flowSurfaces approval blueprint API contract', () => { uid: taskCardSkillsItem.uid, }, changes: { - fieldComponent: 'DisplaySubListFieldModel', + fieldType: 'subDetailsList', }, }, }) diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.contract.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.contract.test.ts index 5d07b89e3b2..b4b055ae99c 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.contract.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.contract.test.ts @@ -567,7 +567,8 @@ describe('flowSurfaces approval API contract', () => { }, }, }); - expect(configureRes.status).toBe(200); + expect(configureRes.status).toBe(400); + expect(readErrorMessage(configureRes)).toContain('fieldComponent'); const applyFieldWrapperReadback = await getSurface(rootAgent, { uid: applyField.wrapperUid, @@ -576,7 +577,7 @@ describe('flowSurfaces approval API contract', () => { uid: applyField.fieldUid, }); - expect(applyFieldWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBe('InputFieldModel'); + expect(applyFieldWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBeUndefined(); expect(applyFieldInnerReadback.tree).toMatchObject({ use: 'PatternFormFieldModel', stepParams: { @@ -919,7 +920,7 @@ describe('flowSurfaces approval API contract', () => { }); }); - it('should switch approval relation field components and expose dynamic fieldComponent enums through catalog', async () => { + it('should switch approval relation fieldTypes and expose dynamic fieldType enums through catalog', async () => { const triggerSurface = await createApprovalSurface(flowRepo, { pageUse: 'TriggerChildPageModel', tabUse: 'TriggerChildPageTabModel', @@ -1038,25 +1039,23 @@ describe('flowSurfaces approval API contract', () => { }), ); - expect(applyManagerCatalog.node.configureOptions.fieldComponent.enum).toEqual([ - 'RecordSelectFieldModel', - 'RecordPickerFieldModel', - 'SubFormFieldModel', + expect(applyManagerCatalog.node.configureOptions.fieldComponent).toBeUndefined(); + expect(applyManagerCatalog.node.configureOptions.fieldType.enum).toEqual(['select', 'picker', 'subForm']); + expect(applySkillsCatalog.node.configureOptions.fieldType.enum).toEqual([ + 'select', + 'picker', + 'subFormList', + 'subTable', + 'popupSubTable', ]); - expect(applySkillsCatalog.node.configureOptions.fieldComponent.enum).toEqual([ - 'RecordSelectFieldModel', - 'RecordPickerFieldModel', - 'SubFormListFieldModel', - 'PatternSubTableFieldModel', - ]); - expect(approvalManagerCatalog.node.configureOptions.fieldComponent.enum).toEqual([ - 'DisplayTextFieldModel', - 'DisplaySubItemFieldModel', - ]); - expect(approvalSkillsCatalog.node.configureOptions.fieldComponent.enum).toEqual([ - 'DisplayTextFieldModel', - 'DisplaySubListFieldModel', - 'DisplaySubTableFieldModel', + expect(approvalManagerCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetails']); + expect(approvalSkillsCatalog.node.configureOptions.fieldType.enum).toEqual(['text', 'subDetailsList', 'subTable']); + expect(applySkillsCatalog.node.relation?.fieldTypes).toEqual([ + 'select', + 'picker', + 'subFormList', + 'subTable', + 'popupSubTable', ]); expect( @@ -1067,7 +1066,7 @@ describe('flowSurfaces approval API contract', () => { uid: applyManagerField.wrapperUid, }, changes: { - fieldComponent: 'RecordPickerFieldModel', + fieldType: 'picker', }, }, }) @@ -1081,7 +1080,7 @@ describe('flowSurfaces approval API contract', () => { uid: applyManagerField.wrapperUid, }, changes: { - fieldComponent: 'SubFormFieldModel', + fieldType: 'subForm', }, }, }) @@ -1095,7 +1094,7 @@ describe('flowSurfaces approval API contract', () => { uid: applySkillsField.wrapperUid, }, changes: { - fieldComponent: 'SubFormListFieldModel', + fieldType: 'subFormList', }, }, }) @@ -1109,7 +1108,7 @@ describe('flowSurfaces approval API contract', () => { uid: applySkillsField.wrapperUid, }, changes: { - fieldComponent: 'PatternSubTableFieldModel', + fieldType: 'subTable', }, }, }) @@ -1123,7 +1122,7 @@ describe('flowSurfaces approval API contract', () => { uid: approvalManagerField.wrapperUid, }, changes: { - fieldComponent: 'DisplaySubItemFieldModel', + fieldType: 'subDetails', }, }, }) @@ -1137,7 +1136,7 @@ describe('flowSurfaces approval API contract', () => { uid: approvalSkillsField.wrapperUid, }, changes: { - fieldComponent: 'DisplaySubListFieldModel', + fieldType: 'subDetailsList', }, }, }) @@ -1151,7 +1150,7 @@ describe('flowSurfaces approval API contract', () => { uid: approvalSkillsField.wrapperUid, }, changes: { - fieldComponent: 'DisplaySubTableFieldModel', + fieldType: 'subTable', }, }, }) @@ -1192,12 +1191,12 @@ describe('flowSurfaces approval API contract', () => { }, }, }); - expect(applySkillsWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBe('PatternSubTableFieldModel'); + expect(applySkillsWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBe('SubTableFieldModel'); expect(applySkillsInnerReadback.tree).toMatchObject({ use: 'PatternFormFieldModel', stepParams: { fieldBinding: { - use: 'PatternSubTableFieldModel', + use: 'SubTableFieldModel', }, }, }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.unit.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.unit.test.ts index d35c4f74308..537eb0eb85c 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.unit.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.approval.unit.test.ts @@ -16,6 +16,7 @@ import { resolveSupportedBlockCatalogItem, } from '../flow-surfaces/catalog'; import { getConfigureOptionsForUse } from '../flow-surfaces/configure-options'; +import { getPublicFieldTypeForUse } from '../flow-surfaces/field-type-resolver'; import { buildPlanKeyKind } from '../flow-surfaces/planning/key-kind'; import { getReactionKindsForUse } from '../flow-surfaces/reaction/registry'; @@ -198,7 +199,7 @@ describe('flowSurfaces approval surface', () => { }); }); - it('should expose approval relation fieldComponent sets that match current frontend wrappers', () => { + it('should expose approval relation fieldType sets that match current frontend wrappers', () => { const singleAssociationField = { interface: 'm2o', targetCollection: { @@ -223,6 +224,11 @@ describe('flowSurfaces approval surface', () => { getSupportedFieldComponentUseSet({ containerUse: 'PatternFormItemModel', field: singleAssociationField }) || [], ), ).toEqual(['RecordSelectFieldModel', 'RecordPickerFieldModel', 'SubFormFieldModel']); + expect( + Array.from( + getSupportedFieldComponentUseSet({ containerUse: 'PatternFormItemModel', field: multiAssociationField }) || [], + ).map((use) => getPublicFieldTypeForUse(use)), + ).toEqual(['select', 'picker', 'subFormList', 'subTable', 'popupSubTable']); expect( Array.from( getSupportedFieldComponentUseSet({ containerUse: 'PatternFormItemModel', field: multiAssociationField }) || [], @@ -231,7 +237,8 @@ describe('flowSurfaces approval surface', () => { 'RecordSelectFieldModel', 'RecordPickerFieldModel', 'SubFormListFieldModel', - 'PatternSubTableFieldModel', + 'SubTableFieldModel', + 'PopupSubTableFieldModel', ]); expect( Array.from( diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.association.contract.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.association.contract.test.ts index fb36882002d..f78b96a0add 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.association.contract.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.association.contract.test.ts @@ -146,7 +146,7 @@ describe('flowSurfaces association contract', () => { }), ); expect(rolesWrapperCatalog.node.configureOptions).toMatchObject({ - fieldComponent: { + fieldType: { type: 'string', }, }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.blueprint-contract.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.blueprint-contract.test.ts index b03e25a30c4..c3fc329f35f 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.blueprint-contract.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.blueprint-contract.test.ts @@ -450,6 +450,157 @@ describe('flowSurfaces applyBlueprint contract', () => { } }); + it('should create flow-model tree blocks through applyBlueprint and reject unsupported tree containers', async () => { + const executeRes = await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + mode: 'create', + navigation: { + item: { + title: 'Tree blueprint', + }, + }, + page: { + title: 'Tree blueprint', + }, + tabs: [ + { + title: 'Tree', + blocks: [ + { + key: 'categoryTree', + type: 'tree', + resource: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + settings: { + searchable: false, + defaultExpandAll: true, + includeDescendants: true, + pageSize: 200, + titleField: 'title', + }, + }, + ], + layout: { + rows: [[{ key: 'categoryTree', span: 8 }]], + }, + }, + ], + }, + }); + + expect(executeRes.status, readErrorMessage(executeRes)).toBe(200); + const data = getData(executeRes); + const treeBlock = collectDescendantNodes(data.surface.tree, (item) => item?.use === 'TreeBlockModel')[0]; + expect(treeBlock).toMatchObject({ + use: 'TreeBlockModel', + props: { + searchable: false, + defaultExpandAll: true, + includeDescendants: true, + pageSize: 200, + fieldNames: { + title: 'title', + }, + }, + stepParams: { + resourceSettings: { + init: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + }, + treeSettings: { + searchable: { + searchable: false, + }, + defaultExpandAll: { + defaultExpandAll: true, + }, + includeDescendants: { + includeDescendants: true, + }, + pageSize: { + pageSize: 200, + }, + titleField: { + titleField: 'title', + }, + }, + }, + }); + expect(treeBlock.subModels).toBeUndefined(); + + const invalidCases = [ + { + key: 'fields', + payload: { + fields: ['title'], + }, + message: 'fields is not supported on tree blocks', + }, + { + key: 'fieldGroups', + payload: { + fieldGroups: [ + { + title: 'Tree fields', + fields: ['title'], + }, + ], + }, + message: 'fieldGroups is not supported on tree blocks', + }, + { + key: 'actions', + payload: { + actions: ['refresh'], + }, + message: 'actions is not supported on tree blocks', + }, + { + key: 'recordActions', + payload: { + recordActions: ['view'], + }, + message: 'recordActions is not supported on tree blocks', + }, + ]; + + for (const item of invalidCases) { + const invalidRes = await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + mode: 'create', + navigation: { + item: { + title: `Invalid tree blueprint ${item.key}`, + }, + }, + page: { + title: `Invalid tree blueprint ${item.key}`, + }, + tabs: [ + { + title: 'Tree', + blocks: [ + { + key: 'categoryTree', + type: 'tree', + collection: 'categories', + ...item.payload, + }, + ], + }, + ], + }, + }); + + expect(invalidRes.status).toBe(400); + expect(readErrorMessage(invalidRes)).toContain(item.message); + } + }); + it('should apply block-level defaultFilter in applyBlueprint data blocks and prefer explicit action settings', async () => { const blockDefaultFilter = { logic: '$and', @@ -4299,6 +4450,52 @@ describe('flowSurfaces applyBlueprint contract', () => { }); }); + it('should apply relation fieldType on blueprint field objects without creating standalone table blocks', async () => { + const executeRes = await rootAgent.resource('flowSurfaces').applyBlueprint({ + values: { + version: '1', + mode: 'create', + page: { + title: 'Blueprint relation fieldType', + }, + tabs: [ + { + key: 'overview', + title: 'Overview', + blocks: [ + { + key: 'userForm', + type: 'createForm', + collection: 'users', + fields: [ + { + key: 'rolesField', + field: 'roles', + fieldType: 'popupSubTable', + fields: ['title', 'name'], + }, + ], + }, + ], + }, + ], + }, + }); + + expect(executeRes.status).toBe(200); + const data = getData(executeRes); + const formBlock = _.castArray(data.surface?.tree?.subModels?.tabs || [])[0]?.subModels?.grid?.subModels?.items?.[0]; + const formItems = _.castArray(formBlock?.subModels?.grid?.subModels?.items || []); + expect(formItems).toHaveLength(1); + expect(formItems[0]?.use).toBe('FormItemModel'); + expect(formItems[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel'); + expect( + _.castArray(formItems[0]?.subModels?.field?.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual(['title', 'name']); + }); + it('should reject fieldsLayout on applyBlueprint blocks that do not own a field grid', async () => { const res = await rootAgent.resource('flowSurfaces').applyBlueprint({ values: { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts index 19d56dd6d71..6b1362415f0 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.builder.unit.test.ts @@ -9,7 +9,12 @@ import { describe, expect, it } from 'vitest'; import { buildApprovalActionDefaults } from '../flow-surfaces/approval/builder'; -import { buildActionTree, buildPopupPageTree, buildStandaloneFieldNode } from '../flow-surfaces/builder'; +import { + buildActionTree, + buildFieldTree, + buildPopupPageTree, + buildStandaloneFieldNode, +} from '../flow-surfaces/builder'; describe('flowSurfaces builder translation defaults', () => { it('should persist translatable core default strings', () => { @@ -91,3 +96,72 @@ describe('flowSurfaces builder translation defaults', () => { }); }); }); + +describe('flowSurfaces builder action style defaults', () => { + it('should align record action button defaults with their container initializers', () => { + const tableEditAction = buildActionTree({ + use: 'EditActionModel', + containerUse: 'TableActionsColumnModel', + }); + const listEditAction = buildActionTree({ + use: 'EditActionModel', + containerUse: 'ListItemModel', + }); + const gridCardEditAction = buildActionTree({ + use: 'EditActionModel', + containerUse: 'GridCardItemModel', + }); + const detailsEditAction = buildActionTree({ + use: 'EditActionModel', + containerUse: 'DetailsBlockModel', + }); + + expect(tableEditAction.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + }); + expect(listEditAction.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + }); + expect(gridCardEditAction.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + }); + expect(detailsEditAction.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'default', + icon: 'EditOutlined', + }); + }); +}); + +describe('flowSurfaces builder relation field defaults', () => { + it('should create PopupSubTableFieldModel with default actions column and edit/remove actions', () => { + const fieldTree = buildFieldTree({ + wrapperUse: 'FormItemModel', + fieldUse: 'PopupSubTableFieldModel', + dataSourceKey: 'main', + collectionName: 'users', + fieldPath: 'roles', + }); + + expect(fieldTree.model).toMatchObject({ + use: 'FormItemModel', + subModels: { + field: { + use: 'PopupSubTableFieldModel', + subModels: { + subTableColumns: [ + { + use: 'PopupSubTableActionsColumnModel', + subModels: { + actions: [{ use: 'PopupSubTableEditActionModel' }, { use: 'PopupSubTableRemoveActionModel' }], + }, + }, + ], + }, + }, + }, + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts index 573fc986d5e..e155c1e09b3 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.catalog-compose.contract.test.ts @@ -231,6 +231,7 @@ describe('flowSurfaces catalog + compose contract', () => { ); expect(pageCatalog.blocks.find((item: any) => item.use === 'ListBlockModel')).toBeUndefined(); expect(pageCatalog.blocks.find((item: any) => item.use === 'GridCardBlockModel')).toBeUndefined(); + expect(pageCatalog.blocks.find((item: any) => item.use === 'TreeBlockModel')).toBeUndefined(); expect(pageCatalog.blocks.find((item: any) => item.use === 'MarkdownBlockModel')).toBeUndefined(); expect(pageCatalog.blocks.find((item: any) => item.use === 'IframeBlockModel')).toBeUndefined(); expect(pageCatalog.blocks.find((item: any) => item.use === 'MapBlockModel')).toBeUndefined(); @@ -283,6 +284,52 @@ describe('flowSurfaces catalog + compose contract', () => { } }); + it('should expose tree block only when block-tree is enabled', async () => { + const page = await createPage(rootAgent, { + title: 'Tree catalog page', + tabTitle: 'Tree catalog tab', + }); + + const fullCatalog = getData( + await rootAgent.resource('flowSurfaces').catalog({ + values: { + target: { + uid: page.tabSchemaUid, + }, + sections: ['blocks'], + }, + }), + ); + const treeItem = fullCatalog.blocks.find((item: any) => item.key === 'tree'); + expect(treeItem).toMatchObject({ + use: 'TreeBlockModel', + requiredInitParams: ['dataSourceKey', 'collectionName'], + createSupported: true, + }); + + await syncFlowSurfacesEnabledPlugins(app, FLOW_SURFACES_MINIMAL_TEST_PLUGINS); + try { + const minimalRootAgent: any = await loginFlowSurfacesRootAgent(app); + const minimalPage = await createPage(minimalRootAgent, { + title: 'Minimal tree catalog page', + tabTitle: 'Minimal tree catalog tab', + }); + const minimalCatalog = getData( + await minimalRootAgent.resource('flowSurfaces').catalog({ + values: { + target: { + uid: minimalPage.tabSchemaUid, + }, + sections: ['blocks'], + }, + }), + ); + expect(minimalCatalog.blocks.find((item: any) => item.key === 'tree')).toBeUndefined(); + } finally { + await syncFlowSurfacesEnabledPlugins(app, FLOW_SURFACES_TEST_PLUGINS); + } + }); + it('should expose public catalog action keys for representative block targets', async () => { const page = await createPage(rootAgent, { title: 'Catalog contract page', @@ -338,6 +385,16 @@ describe('flowSurfaces catalog + compose contract', () => { collectionName: 'calendar_events', }, }); + const tree = await addBlockData(rootAgent, { + target: { + uid: page.tabSchemaUid, + }, + type: 'tree', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + }); const tableCatalog = getData( await rootAgent.resource('flowSurfaces').catalog({ @@ -509,6 +566,37 @@ describe('flowSurfaces catalog + compose contract', () => { calendarCatalog.actions.find((item: any) => item.key === 'turnPages')?.configureOptions?.title, ).toBeUndefined(); + const treeCatalog = getData( + await rootAgent.resource('flowSurfaces').catalog({ + values: { + target: { + uid: tree.uid, + }, + expand: ['item.configureOptions'], + }, + }), + ); + expect(treeCatalog.actions || []).toEqual([]); + expect(treeCatalog.recordActions || []).toEqual([]); + expect(treeCatalog.fields || []).toEqual([]); + expect(treeCatalog.node.configureOptions).toMatchObject({ + searchable: { + type: 'boolean', + }, + defaultExpandAll: { + type: 'boolean', + }, + includeDescendants: { + type: 'boolean', + }, + titleField: { + type: 'string', + }, + pageSize: { + type: 'number', + }, + }); + const createFormCatalog = getData( await rootAgent.resource('flowSurfaces').catalog({ values: { @@ -843,6 +931,131 @@ describe('flowSurfaces catalog + compose contract', () => { expect(readErrorMessage(invalidCollectionRes)).toContain(`must contain at least one date field`); }); + it('should create configure and batch-add flow-model tree blocks', async () => { + const page = await createPage(rootAgent, { + title: 'Tree block contract page', + tabTitle: 'Tree block contract tab', + }); + + const tree = await addBlockData(rootAgent, { + target: { + uid: page.tabSchemaUid, + }, + type: 'tree', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + }); + const treeReadback = await getSurface(rootAgent, { + uid: tree.uid, + }); + expect(treeReadback.tree).toMatchObject({ + use: 'TreeBlockModel', + props: { + searchable: true, + defaultExpandAll: false, + includeDescendants: true, + }, + stepParams: { + resourceSettings: { + init: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + }, + }, + }); + expect(treeReadback.tree.subModels).toBeUndefined(); + + const configureRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: tree.uid, + }, + changes: { + searchable: false, + defaultExpandAll: true, + includeDescendants: false, + titleField: 'title', + pageSize: 200, + height: 420, + }, + }, + }); + expect(configureRes.status, readErrorMessage(configureRes)).toBe(200); + const configuredTree = await getSurface(rootAgent, { + uid: tree.uid, + }); + expect(configuredTree.tree.props).toMatchObject({ + searchable: false, + defaultExpandAll: true, + includeDescendants: false, + fieldNames: { + title: 'title', + }, + pageSize: 200, + }); + expect(configuredTree.tree.decoratorProps).toMatchObject({ + height: 420, + }); + expect(configuredTree.tree.stepParams?.treeSettings).toMatchObject({ + searchable: { + searchable: false, + }, + defaultExpandAll: { + defaultExpandAll: true, + }, + includeDescendants: { + includeDescendants: false, + }, + titleField: { + titleField: 'title', + }, + pageSize: { + pageSize: 200, + }, + }); + + const batchPage = await createPage(rootAgent, { + title: 'Batch tree block page', + tabTitle: 'Batch tree block tab', + }); + const batchRes = await rootAgent.resource('flowSurfaces').addBlocks({ + values: { + target: { + uid: batchPage.tabSchemaUid, + }, + blocks: [ + { + key: 'treeA', + type: 'tree', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + }, + { + key: 'treeB', + use: 'TreeBlockModel', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'departments', + }, + settings: { + searchable: false, + }, + }, + ], + }, + }); + expect(batchRes.status).toBe(200); + const batchData = getData(batchRes); + expect(batchData.successCount).toBe(2); + expect(batchData.errorCount).toBe(0); + expect(batchData.blocks.map((item: any) => item.result?.uid).filter(Boolean)).toHaveLength(2); + }); + it('should project missing calendar popup hosts during readback without persisting them', async () => { const page = await createPage(rootAgent, { title: 'Calendar read projection page', @@ -1885,6 +2098,468 @@ describe('flowSurfaces catalog + compose contract', () => { expect(usernameFilterReadback.tree.stepParams?.filterFormItemSettings?.init?.defaultTargetUid).toBe(tableBlock.uid); }); + it('should compose tree blocks with layout and reject unsupported tree content containers', async () => { + const page = await createPage(rootAgent, { + title: 'Compose tree page', + tabTitle: 'Compose tree tab', + }); + + const composeRes = await rootAgent.resource('flowSurfaces').compose({ + values: { + target: { + uid: page.tabSchemaUid, + }, + blocks: [ + { + key: 'categoryTree', + type: 'tree', + resource: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + settings: { + searchable: true, + defaultExpandAll: true, + includeDescendants: true, + pageSize: 200, + titleField: 'title', + }, + }, + ], + layout: { + rows: [[{ key: 'categoryTree', span: 8 }]], + }, + }, + }); + expect(composeRes.status).toBe(200); + const composed = getData(composeRes); + const treeBlock = getComposeBlock(composed, 'categoryTree'); + expect(composed.layout.rows.row1).toEqual([[treeBlock.uid]]); + expect(composed.layout.sizes.row1).toEqual([24]); + + const treeReadback = await getSurface(rootAgent, { + uid: treeBlock.uid, + }); + expect(treeReadback.tree).toMatchObject({ + use: 'TreeBlockModel', + props: { + searchable: true, + defaultExpandAll: true, + includeDescendants: true, + pageSize: 200, + fieldNames: { + title: 'title', + }, + }, + stepParams: { + resourceSettings: { + init: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + }, + treeSettings: { + defaultExpandAll: { + defaultExpandAll: true, + }, + includeDescendants: { + includeDescendants: true, + }, + pageSize: { + pageSize: 200, + }, + titleField: { + titleField: 'title', + }, + }, + }, + }); + expect(treeReadback.tree.subModels).toBeUndefined(); + + const invalidCases = [ + { + key: 'fields', + payload: { + fields: ['title'], + }, + message: 'tree does not support fields[]', + }, + { + key: 'fieldGroups', + payload: { + fieldGroups: [ + { + title: 'Tree fields', + fields: ['title'], + }, + ], + }, + message: 'tree does not support fieldGroups[]', + }, + { + key: 'actions', + payload: { + actions: ['refresh'], + }, + message: 'tree does not support actions[]', + }, + { + key: 'recordActions', + payload: { + recordActions: ['view'], + }, + message: 'tree does not support recordActions[]', + }, + ]; + + for (const item of invalidCases) { + const invalidPage = await createPage(rootAgent, { + title: `Invalid tree compose ${item.key}`, + tabTitle: `Invalid tree compose ${item.key}`, + }); + const invalidRes = await rootAgent.resource('flowSurfaces').compose({ + values: { + target: { + uid: invalidPage.tabSchemaUid, + }, + blocks: [ + { + key: 'tree', + type: 'tree', + resource: { + dataSourceKey: 'main', + collectionName: 'categories', + }, + ...item.payload, + }, + ], + }, + }); + expect(invalidRes.status).toBe(400); + expect(readErrorMessage(invalidRes)).toContain(item.message); + } + }); + + it('should compose relation fields with fieldType on form blocks', async () => { + const page = await createPage(rootAgent, { + title: 'Compose relation fieldType page', + tabTitle: 'Compose relation fieldType tab', + }); + + const composeRes = getData( + await rootAgent.resource('flowSurfaces').compose({ + values: { + target: { + uid: page.tabSchemaUid, + }, + blocks: [ + { + key: 'userForm', + type: 'createForm', + resource: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'rolesField', + fieldPath: 'roles', + fieldType: 'popupSubTable', + fields: ['title', 'name'], + }, + ], + }, + ], + }, + }), + ); + + const formBlock = getComposeBlock(composeRes, 'userForm'); + const formSurface = await getSurface(rootAgent, { + uid: formBlock.uid, + }); + const formItems = _.castArray(formSurface.tree?.subModels?.grid?.subModels?.items || []); + expect(formItems).toHaveLength(1); + expect(formItems[0]?.use).toBe('FormItemModel'); + expect(formItems[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel'); + expect( + _.castArray(formItems[0]?.subModels?.field?.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual(['title', 'name']); + + const fieldCatalog = getData( + await rootAgent.resource('flowSurfaces').catalog({ + values: { + target: { + uid: formItems[0].uid, + }, + }, + }), + ); + expect(fieldCatalog.node.relation?.fieldTypes).toEqual( + expect.arrayContaining(['select', 'picker', 'subFormList', 'popupSubTable']), + ); + expect(fieldCatalog.node.relation?.current).toMatchObject({ + fieldType: 'popupSubTable', + fields: ['title', 'name'], + titleField: 'title', + }); + expect(fieldCatalog.node.relation?.candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldType: 'picker', + defaults: expect.objectContaining({ + titleField: 'title', + selectorFields: ['title'], + }), + }), + expect.objectContaining({ + fieldType: 'popupSubTable', + defaults: expect.objectContaining({ + titleField: 'title', + fields: ['title'], + }), + }), + ]), + ); + }); + + it('should compose inline editable subTable columns with editable fields', async () => { + const page = await createPage(rootAgent, { + title: 'Compose editable relation subTable page', + tabTitle: 'Compose editable relation subTable tab', + }); + + const composeRes = getData( + await rootAgent.resource('flowSurfaces').compose({ + values: { + target: { + uid: page.tabSchemaUid, + }, + blocks: [ + { + key: 'userForm', + type: 'createForm', + resource: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'rolesField', + fieldPath: 'roles', + fieldType: 'subTable', + fields: ['title', 'name'], + }, + ], + }, + ], + }, + }), + ); + + const formBlock = getComposeBlock(composeRes, 'userForm'); + const formSurface = await getSurface(rootAgent, { + uid: formBlock.uid, + }); + const formItems = _.castArray(formSurface.tree?.subModels?.grid?.subModels?.items || []); + expect(formItems).toHaveLength(1); + const rolesField = formItems[0]?.subModels?.field; + expect(rolesField?.use).toBe('SubTableFieldModel'); + + const columns = _.castArray(rolesField?.subModels?.columns || []); + expect(columns.map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath)).toEqual(['title', 'name']); + expect(columns.map((item: any) => item?.subModels?.field?.use)).toEqual(['InputFieldModel', 'InputFieldModel']); + }); + + it('should preserve explicit empty relation fields in compose fieldType specs', async () => { + const page = await createPage(rootAgent, { + title: 'Compose empty relation fieldType page', + tabTitle: 'Compose empty relation fieldType tab', + }); + + const composeRes = getData( + await rootAgent.resource('flowSurfaces').compose({ + values: { + target: { + uid: page.tabSchemaUid, + }, + blocks: [ + { + key: 'userForm', + type: 'createForm', + resource: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'rolesField', + fieldPath: 'roles', + fieldType: 'popupSubTable', + fields: [], + }, + ], + }, + ], + }, + }), + ); + + const formBlock = getComposeBlock(composeRes, 'userForm'); + const formSurface = await getSurface(rootAgent, { + uid: formBlock.uid, + }); + const formItems = _.castArray(formSurface.tree?.subModels?.grid?.subModels?.items || []); + expect( + _.castArray(formItems[0]?.subModels?.field?.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual([]); + }); + + it('should addBlock with inline fields that use relation fieldType semantics', async () => { + const page = await createPage(rootAgent, { + title: 'AddBlock inline relation fields page', + tabTitle: 'AddBlock inline relation fields tab', + }); + + const addBlockByTypeRes = getData( + await rootAgent.resource('flowSurfaces').addBlock({ + values: { + target: { + uid: page.tabSchemaUid, + }, + type: 'createForm', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'rolesField', + fieldPath: 'roles', + fieldType: 'popupSubTable', + fields: ['title'], + }, + ], + }, + }), + ); + + const blockByTypeSurface = await getSurface(rootAgent, { + uid: addBlockByTypeRes.uid, + }); + const formItemsByType = _.castArray(blockByTypeSurface.tree?.subModels?.grid?.subModels?.items || []); + expect(formItemsByType).toHaveLength(1); + expect(formItemsByType[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel'); + expect( + _.castArray(formItemsByType[0]?.subModels?.field?.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual(['title']); + + const addBlockByUseRes = getData( + await rootAgent.resource('flowSurfaces').addBlock({ + values: { + target: { + uid: page.tabSchemaUid, + }, + use: 'CreateFormModel', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'rolesFieldByUse', + fieldPath: 'roles', + fieldType: 'popupSubTable', + }, + ], + }, + }), + ); + + const blockByUseSurface = await getSurface(rootAgent, { + uid: addBlockByUseRes.uid, + }); + const formItemsByUse = _.castArray(blockByUseSurface.tree?.subModels?.grid?.subModels?.items || []); + expect(formItemsByUse).toHaveLength(1); + expect(formItemsByUse[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel'); + + const addBlocksData = getData( + await rootAgent.resource('flowSurfaces').addBlocks({ + values: { + target: { + uid: page.tabSchemaUid, + }, + blocks: [ + { + key: 'batchUserForm', + type: 'createForm', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'batchRolesField', + fieldPath: 'roles', + fieldType: 'popupSubTable', + }, + ], + }, + ], + }, + }), + ); + + expect(addBlocksData.successCount).toBe(1); + const batchBlockSurface = await getSurface(rootAgent, { + uid: addBlocksData.blocks[0].result.uid, + }); + const batchFormItems = _.castArray(batchBlockSurface.tree?.subModels?.grid?.subModels?.items || []); + expect(batchFormItems).toHaveLength(1); + expect(batchFormItems[0]?.subModels?.field?.use).toBe('PopupSubTableFieldModel'); + }); + + it('should reject internal relation field model keys in public field specs', async () => { + const page = await createPage(rootAgent, { + title: 'Reject internal relation field keys page', + tabTitle: 'Reject internal relation field keys tab', + }); + + const composeRes = await rootAgent.resource('flowSurfaces').compose({ + values: { + target: { + uid: page.tabSchemaUid, + }, + blocks: [ + { + key: 'userForm', + type: 'createForm', + resource: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'rolesField', + fieldPath: 'roles', + fieldComponent: 'PopupSubTableFieldModel', + }, + ], + }, + ], + }, + }); + + expect(composeRes.status).toBe(400); + expect(readErrorMessage(composeRes)).toContain('does not accept internal field keys'); + }); + it('should reject fieldsLayout on compose blocks that do not own a field grid', async () => { const page = await createPage(rootAgent, { title: 'Invalid compose fieldsLayout page', @@ -2334,6 +3009,96 @@ describe('flowSurfaces catalog + compose contract', () => { ); }); + it('should create list and grid-card record actions with frontend-aligned button defaults', async () => { + const page = await createPage(rootAgent, { + title: 'Record action style page', + tabTitle: 'Record action style tab', + }); + const list = await addBlockData(rootAgent, { + target: { + uid: page.tabSchemaUid, + }, + type: 'list', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'users', + }, + }); + const gridCard = await addBlockData(rootAgent, { + target: { + uid: page.tabSchemaUid, + }, + type: 'gridCard', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'users', + }, + }); + + const listEditAction = getData( + await rootAgent.resource('flowSurfaces').addRecordAction({ + values: { + target: { + uid: list.uid, + }, + type: 'edit', + settings: { + title: '编辑', + }, + }, + }), + ); + const gridCardViewAction = getData( + await rootAgent.resource('flowSurfaces').addRecordAction({ + values: { + target: { + uid: gridCard.uid, + }, + type: 'view', + }, + }), + ); + const explicitListEditAction = getData( + await rootAgent.resource('flowSurfaces').addRecordAction({ + values: { + target: { + uid: list.uid, + }, + type: 'edit', + settings: { + type: 'primary', + icon: 'EditOutlined', + }, + }, + }), + ); + + const listEditReadback = await getSurface(rootAgent, { + uid: listEditAction.uid, + }); + expect(listEditReadback.tree.stepParams?.buttonSettings?.general).toMatchObject({ + title: '编辑', + type: 'link', + icon: null, + }); + + const gridCardViewReadback = await getSurface(rootAgent, { + uid: gridCardViewAction.uid, + }); + expect(gridCardViewReadback.tree.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'link', + icon: null, + }); + + const explicitListEditReadback = await getSurface(rootAgent, { + uid: explicitListEditAction.uid, + }); + expect(explicitListEditReadback.tree.stepParams?.buttonSettings?.general).toMatchObject({ + type: 'primary', + icon: 'EditOutlined', + }); + }); + it('should compose a grid-card block with item fields block actions and record actions', async () => { const page = await createPage(rootAgent, { title: 'Compose grid-card page', @@ -3674,7 +4439,7 @@ describe('flowSurfaces catalog + compose contract', () => { }, }); expect(rawFieldRes.status).toBe(400); - expect(readErrorMessage(rawFieldRes)).toContain('does not accept raw keys'); + expect(readErrorMessage(rawFieldRes)).toContain('does not accept internal field keys'); const rawActionRes = await rootAgent.resource('flowSurfaces').addAction({ values: { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-binding-registry.unit.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-binding-registry.unit.test.ts index 98881414b48..56636eef83d 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-binding-registry.unit.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-binding-registry.unit.test.ts @@ -251,4 +251,46 @@ describe('flowSurfaces field binding registry', () => { })?.has('CheckboxGroupFieldModel'), ).toBe(true); }); + + it('should expose generic relation field component sets that back public fieldType options', () => { + const singleAssociationField = { + interface: 'm2o', + targetCollection: { + template: 'general', + }, + }; + const multiAssociationField = { + interface: 'm2m', + targetCollection: { + template: 'general', + }, + }; + + expect( + Array.from( + getSupportedFieldComponentUseSet({ containerUse: 'FormItemModel', field: singleAssociationField }) || [], + ), + ).toEqual(['RecordSelectFieldModel', 'RecordPickerFieldModel', 'SubFormFieldModel']); + expect( + Array.from( + getSupportedFieldComponentUseSet({ containerUse: 'FormItemModel', field: multiAssociationField }) || [], + ), + ).toEqual([ + 'RecordSelectFieldModel', + 'RecordPickerFieldModel', + 'SubFormListFieldModel', + 'SubTableFieldModel', + 'PopupSubTableFieldModel', + ]); + expect( + Array.from( + getSupportedFieldComponentUseSet({ containerUse: 'DetailsItemModel', field: multiAssociationField }) || [], + ), + ).toEqual(['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel']); + expect( + Array.from( + getSupportedFieldComponentUseSet({ containerUse: 'TableColumnModel', field: multiAssociationField }) || [], + ), + ).toEqual(['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel']); + }); }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts index 4d0fa712a6d..4d43cc4101e 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.swagger.test.ts @@ -501,6 +501,7 @@ describe('flowSurfaces swagger', () => { 'chart', 'actionPanel', 'jsBlock', + 'tree', ]); expect(schemas.FlowSurfaceApplyBlueprintBlockSpec.properties.type.description).toContain( 'Generic `form` is not supported', @@ -944,6 +945,7 @@ describe('flowSurfaces swagger', () => { expect.arrayContaining([ 'table', 'filterForm', + 'tree', 'actionPanel', 'jsBlock', 'approvalInitiator', @@ -1217,6 +1219,12 @@ describe('flowSurfaces swagger', () => { expect(schemas.FlowSurfaceAddBlockRequest.properties.resource.$ref).toBe( '#/components/schemas/FlowSurfaceBlockResourceInput', ); + expect(schemas.FlowSurfaceAddBlockRequest.properties.fields.items.$ref).toBe( + '#/components/schemas/FlowSurfaceComposeFieldSpec', + ); + expect(schemas.FlowSurfaceAddBlockRequest.properties.fieldsLayout.$ref).toBe( + '#/components/schemas/FlowSurfaceComposeLayout', + ); expect(schemas.FlowSurfaceAddBlockRequest.properties.settings.type).toBe('object'); expect(schemas.FlowSurfaceAddBlockRequest.properties.defaultFilter.allOf).toEqual([ { $ref: '#/components/schemas/FlowSurfaceFilterGroup' }, @@ -1338,11 +1346,12 @@ describe('flowSurfaces swagger', () => { expect(swaggerDocument.paths['/flowSurfaces:addBlocks'].post.description).toContain('`kanban`'); expect(addBlocksRequest.example.blocks).toHaveLength(2); expect(addBlocksRequest.example.blocks[0].type).toBe('table'); - expect(addBlocksRequest.example.blocks[1].type).toBe('markdown'); + expect(addBlocksRequest.example.blocks[1].type).toBe('createForm'); expect(addBlocksRequest.example.blocks[0].settings.pageSize).toBe(50); expect(addBlocksRequest.example.blocks[0].defaultFilter.items[0].value).toBe('staff'); expect(addBlocksRequest.example.blocks[0].defaultActionSettings.filter.defaultFilter.items).toHaveLength(3); - expect(addBlocksRequest.example.blocks[1].settings.content).toContain('Team notes'); + expect(addBlocksRequest.example.blocks[1].fields[0].fieldType).toBe('popupSubTable'); + expect(addBlocksRequest.example.blocks[1].fieldsLayout.rows).toEqual([['rolesField']]); expect(schemas.FlowSurfaceAddBlocksRequest.required).toEqual(['target', 'blocks']); expect(schemas.FlowSurfaceAddBlockItem.properties.settings.type).toBe('object'); expect(schemas.FlowSurfaceAddBlockItem.properties.defaultFilter.allOf).toEqual([ @@ -1354,6 +1363,12 @@ describe('flowSurfaces swagger', () => { expect(schemas.FlowSurfaceAddBlockItem.properties.template.$ref).toBe( '#/components/schemas/FlowSurfaceBlockTemplateRef', ); + expect(schemas.FlowSurfaceAddBlockItem.properties.fields.items.$ref).toBe( + '#/components/schemas/FlowSurfaceComposeFieldSpec', + ); + expect(schemas.FlowSurfaceAddBlockItem.properties.fieldsLayout.$ref).toBe( + '#/components/schemas/FlowSurfaceComposeLayout', + ); expect(schemas.FlowSurfaceComposeBlockSpec.anyOf).toEqual( expect.arrayContaining([{ required: ['type'] }, { required: ['template'] }]), ); @@ -1380,9 +1395,9 @@ describe('flowSurfaces swagger', () => { swaggerDocument.paths['/flowSurfaces:addFields'].post.requestBody.content['application/json']; expect(addFieldsRequest.example.fields).toHaveLength(2); expect(addFieldsRequest.example.fields[0].popup.blocks[0].type).toBe('details'); - expect(addFieldsRequest.example.fields[1].renderer).toBe('js'); + expect(addFieldsRequest.example.fields[1].fieldType).toBe('popupSubTable'); expect(addFieldsRequest.example.fields[0].settings.title).toBe('User name'); - expect(addFieldsRequest.example.fields[1].settings.version).toBe('1.0.0'); + expect(addFieldsRequest.example.fields[1].settings.title).toBe('Roles'); expect(schemas.FlowSurfaceAddFieldsRequest.required).toEqual(['target']); expect(schemas.FlowSurfaceAddFieldsRequest.oneOf).toHaveLength(2); expect(schemas.FlowSurfaceAddFieldsRequest.properties.template.$ref).toBe( @@ -1395,6 +1410,10 @@ describe('flowSurfaces swagger', () => { expect(schemas.FlowSurfaceAddFieldItem.properties.popup.$ref).toBe( '#/components/schemas/FlowSurfaceComposeFieldPopup', ); + expect(schemas.FlowSurfaceAddFieldRequest.properties.fieldType.enum).toContain('popupSubTable'); + expect(schemas.FlowSurfaceAddFieldItem.properties.fieldType.enum).toContain('popupSubTable'); + expect(schemas.FlowSurfaceComposeFieldSpec.oneOf[1].properties.fieldType.enum).toContain('popupSubTable'); + expect(schemas.FlowSurfaceApplyBlueprintFieldSpec.oneOf[1].properties.fieldType.enum).toContain('popupSubTable'); expect(schemas.FlowSurfaceAddFieldItem.properties.wrapperProps).toBeUndefined(); expect(schemas.FlowSurfaceAddFieldItem.properties.fieldProps).toBeUndefined(); expect(schemas.FlowSurfaceAddFieldItem.properties.props).toBeUndefined(); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test-plugins.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test-plugins.ts index da23573490d..b9985be87f4 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test-plugins.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test-plugins.ts @@ -30,6 +30,7 @@ export const FLOW_SURFACES_TEST_PLUGINS = [ 'system-settings', 'block-list', 'block-grid-card', + 'block-tree', 'kanban', 'block-markdown', 'block-iframe', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test.ts index 30c281baf59..c436eb50b21 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.test.ts @@ -1160,6 +1160,65 @@ describe('flowSurfaces resource', () => { expect(expandCollapseReadback.tree.stepParams?.buttonSettings?.general?.title).toBeUndefined(); }); + it('should ignore button type for actions directly under an action panel', async () => { + const page = await createPage(rootAgent, { + title: 'Action panel button type page', + tabTitle: 'Action panel button type tab', + }); + const actionPanelUid = await addBlock(rootAgent, page.tabSchemaUid, 'actionPanel', {}); + const createFormUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', { + dataSourceKey: 'main', + collectionName: 'employees', + }); + + const panelJsAction = await addAction(rootAgent, actionPanelUid, 'js', { + settings: { + title: 'Run panel code', + type: 'primary', + code: "return 'panel';", + version: '1.0.0', + }, + }); + const panelReadback = await getSurface(rootAgent, { uid: panelJsAction.uid }); + expect(panelReadback.tree.use).toBe('JSActionModel'); + expect(panelReadback.tree.props?.title).toBe('Run panel code'); + expect(panelReadback.tree.stepParams?.buttonSettings?.general?.title).toBe('Run panel code'); + expect(panelReadback.tree.stepParams?.clickSettings?.runJs).toMatchObject({ + code: "return 'panel';", + version: '1.0.0', + }); + expect(panelReadback.tree.props?.type).not.toBe('primary'); + expect(panelReadback.tree.stepParams?.buttonSettings?.general?.type).not.toBe('primary'); + + const configurePanelRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { uid: panelJsAction.uid }, + changes: { + title: 'Run panel code configured', + type: 'primary', + }, + }, + }); + expect(configurePanelRes.status).toBe(200); + const configuredPanelReadback = await getSurface(rootAgent, { uid: panelJsAction.uid }); + expect(configuredPanelReadback.tree.props?.title).toBe('Run panel code configured'); + expect(configuredPanelReadback.tree.props?.type).not.toBe('primary'); + expect(configuredPanelReadback.tree.stepParams?.buttonSettings?.general?.type).not.toBe('primary'); + + const formJsAction = await addAction(rootAgent, createFormUid, 'js', { + settings: { + title: 'Run form code', + type: 'primary', + code: "return 'form';", + version: '1.0.0', + }, + }); + const formReadback = await getSurface(rootAgent, { uid: formJsAction.uid }); + expect(formReadback.tree.use).toBe('JSFormActionModel'); + expect(formReadback.tree.props?.type).toBe('primary'); + expect(formReadback.tree.stepParams?.buttonSettings?.general?.type).toBe('primary'); + }); + it('should persist canonical block headers through configure for representative block families', async () => { const page = await createPage(rootAgent, { title: 'Canonical block header page', @@ -4154,7 +4213,7 @@ describe('flowSurfaces resource', () => { expect(readErrorMessage(invalidRes)).toContain('must reference an existing node'); }); - it('should configure roles table fields with displayStyle tag and switch fieldComponent via wrapper changes', async () => { + it('should configure roles table fields with displayStyle tag and switch fieldType via wrapper changes', async () => { const page = await createPage(rootAgent, { title: 'Users roles field configure page', tabTitle: 'Users roles field configure tab', @@ -4191,7 +4250,8 @@ describe('flowSurfaces resource', () => { uid: rolesField.wrapperUid, }, changes: { - fieldComponent: 'DisplaySubTableFieldModel', + fieldType: 'subTable', + fields: ['title', 'name'], }, }, }); @@ -4206,6 +4266,11 @@ describe('flowSurfaces resource', () => { expect(rolesWrapperAfterSwitch.tree.stepParams?.tableColumnSettings?.model?.use).toBe('DisplaySubTableFieldModel'); expect(rolesInnerAfterSwitch.tree.use).toBe('DisplaySubTableFieldModel'); expect(rolesInnerAfterSwitch.tree.stepParams?.fieldBinding?.use).toBe('DisplaySubTableFieldModel'); + expect( + _.castArray(rolesInnerAfterSwitch.tree.subModels?.columns || []).map( + (item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath, + ), + ).toEqual(['title', 'name']); expect(rolesInnerAfterSwitch.tree.stepParams?.fieldSettings?.init).toMatchObject({ dataSourceKey: 'main', collectionName: 'users', @@ -4213,6 +4278,452 @@ describe('flowSurfaces resource', () => { }); }); + it('should create and switch generic relation fields with fieldType semantics', async () => { + const page = await createPage(rootAgent, { + title: 'Generic relation fieldType page', + tabTitle: 'Generic relation fieldType tab', + }); + + const formUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', { + dataSourceKey: 'main', + collectionName: 'users', + }); + + const rolesCatalog = getData( + await rootAgent.resource('flowSurfaces').catalog({ + values: { + target: { + uid: formUid, + }, + }, + }), + ).fields.find((item: any) => item.key === 'roles'); + expect(rolesCatalog.fieldUse).toBe('RecordSelectFieldModel'); + + const createdRolesField = getData( + await rootAgent.resource('flowSurfaces').addField({ + values: { + target: { + uid: formUid, + }, + fieldPath: 'roles', + fieldType: 'popupSubTable', + fields: ['title', 'name'], + }, + }), + ); + expect(createdRolesField.fieldUse).toBe('PopupSubTableFieldModel'); + + const createdRolesWrapperReadback = await getSurface(rootAgent, { + uid: createdRolesField.wrapperUid, + }); + const createdRolesInnerReadback = await getSurface(rootAgent, { + uid: createdRolesField.fieldUid, + }); + expect(createdRolesWrapperReadback.tree.stepParams?.editItemSettings?.model?.use).toBeUndefined(); + expect(createdRolesInnerReadback.tree.use).toBe('PopupSubTableFieldModel'); + expect(createdRolesInnerReadback.tree.subModels?.subTableColumns?.[0]?.use).toBe('PopupSubTableActionsColumnModel'); + expect( + _.castArray(createdRolesInnerReadback.tree.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual(['title', 'name']); + expect( + _.castArray(createdRolesInnerReadback.tree.subModels?.subTableColumns?.[0]?.subModels?.actions || []).map( + (item: any) => item.use, + ), + ).toEqual(['PopupSubTableEditActionModel', 'PopupSubTableRemoveActionModel']); + + const addFieldsData = getData( + await rootAgent.resource('flowSurfaces').addFields({ + values: { + target: { + uid: formUid, + }, + fields: [ + { + key: 'batchRolesField', + fieldPath: 'roles', + fieldType: 'popupSubTable', + }, + ], + }, + }), + ); + expect(addFieldsData.successCount).toBe(1); + expect(addFieldsData.fields[0].ok).toBe(true); + expect(addFieldsData.fields[0].result.fieldUse).toBe('PopupSubTableFieldModel'); + const batchRolesInnerReadback = await getSurface(rootAgent, { + uid: addFieldsData.fields[0].result.fieldUid, + }); + expect(batchRolesInnerReadback.tree.use).toBe('PopupSubTableFieldModel'); + expect(batchRolesInnerReadback.tree.subModels?.subTableColumns?.[0]?.use).toBe('PopupSubTableActionsColumnModel'); + + const defaultedSwitchField = await addField(rootAgent, formUid, 'roles'); + const defaultedSwitchRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: defaultedSwitchField.wrapperUid, + }, + changes: { + fieldType: 'popupSubTable', + }, + }, + }); + expect(defaultedSwitchRes.status).toBe(200); + const defaultedSwitchReadback = await getSurface(rootAgent, { + uid: defaultedSwitchField.fieldUid, + }); + expect( + _.castArray(defaultedSwitchReadback.tree.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual(['title']); + + const defaultRolesField = await addField(rootAgent, formUid, 'roles'); + const switchRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: defaultRolesField.wrapperUid, + }, + changes: { + fieldType: 'popupSubTable', + fields: ['title'], + }, + }, + }); + expect(switchRes.status).toBe(200); + + const switchedRolesWrapper = await getSurface(rootAgent, { + uid: defaultRolesField.wrapperUid, + }); + const switchedRolesInner = await getSurface(rootAgent, { + uid: defaultRolesField.fieldUid, + }); + expect(switchedRolesWrapper.tree.stepParams?.editItemSettings?.model?.use).toBe('PopupSubTableFieldModel'); + expect(switchedRolesInner.tree.stepParams?.fieldBinding?.use).toBe('PopupSubTableFieldModel'); + expect(switchedRolesInner.tree.subModels?.subTableColumns?.[0]?.use).toBe('PopupSubTableActionsColumnModel'); + expect( + _.castArray(switchedRolesInner.tree.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual(['title']); + + const configureInnerTitleFieldRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: defaultRolesField.fieldUid, + }, + changes: { + titleField: 'title', + }, + }, + }); + expect(configureInnerTitleFieldRes.status).toBe(200); + const innerTitleFieldReadback = await getSurface(rootAgent, { + uid: defaultRolesField.fieldUid, + }); + expect(innerTitleFieldReadback.tree.props?.titleField).toBe('title'); + + const customActionsColumn = innerTitleFieldReadback.tree.subModels?.subTableColumns?.[0]; + expect(customActionsColumn?.use).toBe('PopupSubTableActionsColumnModel'); + await flowRepo.patch({ + uid: customActionsColumn.uid, + props: { + ...(customActionsColumn.props || {}), + reviewMarker: 'preserve-on-same-field-component', + }, + }); + const sameFieldComponentRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: defaultRolesField.wrapperUid, + }, + changes: { + fieldType: 'popupSubTable', + }, + }, + }); + expect(sameFieldComponentRes.status).toBe(200); + const sameFieldComponentReadback = await getSurface(rootAgent, { + uid: defaultRolesField.fieldUid, + }); + expect(sameFieldComponentReadback.tree.subModels?.subTableColumns?.[0]?.props?.reviewMarker).toBe( + 'preserve-on-same-field-component', + ); + expect(sameFieldComponentReadback.tree.stepParams?.fieldBinding?.use).toBe('PopupSubTableFieldModel'); + + const invalidScalarFieldRes = await rootAgent.resource('flowSurfaces').addField({ + values: { + target: { + uid: formUid, + }, + fieldPath: 'nickname', + fieldType: 'popupSubTable', + }, + }); + expect(invalidScalarFieldRes.status).toBe(400); + expect(readErrorMessage(invalidScalarFieldRes)).toContain(`fieldType is only supported for relation fields`); + }); + + it('should validate and configure relation fieldType options consistently', async () => { + const page = await createPage(rootAgent, { + title: 'Relation fieldType options page', + tabTitle: 'Relation fieldType options tab', + }); + + const formUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', { + dataSourceKey: 'main', + collectionName: 'users', + }); + const detailsUid = await addBlock(rootAgent, page.tabSchemaUid, 'details', { + dataSourceKey: 'main', + collectionName: 'users', + }); + const employeeFormUid = await addBlock(rootAgent, page.tabSchemaUid, 'createForm', { + dataSourceKey: 'main', + collectionName: 'employees', + }); + const employeeDetailsUid = await addBlock(rootAgent, page.tabSchemaUid, 'details', { + dataSourceKey: 'main', + collectionName: 'employees', + }); + + const pickerField = await addField(rootAgent, formUid, 'roles', { + fieldType: 'picker', + selectorFields: ['title'], + openMode: 'drawer', + popupSize: 'medium', + }); + let pickerReadback = await getSurface(rootAgent, { + uid: pickerField.fieldUid, + }); + expect(pickerReadback.tree.use).toBe('RecordPickerFieldModel'); + expect(pickerReadback.tree.stepParams?.popupSettings?.openView).toMatchObject({ + mode: 'drawer', + size: 'medium', + }); + expect( + _.castArray(pickerReadback.tree.subModels?.['grid-block']?.subModels?.items?.[0]?.subModels?.columns || []).map( + (item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath, + ), + ).toEqual(['title']); + + const configurePickerOnlyRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: pickerField.wrapperUid, + }, + changes: { + openMode: 'modal', + popupSize: 'large', + }, + }, + }); + expect(configurePickerOnlyRes.status).toBe(200); + pickerReadback = await getSurface(rootAgent, { + uid: pickerField.fieldUid, + }); + expect(pickerReadback.tree.stepParams?.popupSettings?.openView).toMatchObject({ + mode: 'modal', + size: 'large', + }); + + const clearPickerSelectorFieldsRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: pickerField.wrapperUid, + }, + changes: { + selectorFields: [], + }, + }, + }); + expect(clearPickerSelectorFieldsRes.status).toBe(200); + pickerReadback = await getSurface(rootAgent, { + uid: pickerField.fieldUid, + }); + expect( + _.castArray(pickerReadback.tree.subModels?.['grid-block']?.subModels?.items?.[0]?.subModels?.columns || []).map( + (item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath, + ), + ).toEqual([]); + + const customPickerField = await addField(rootAgent, employeeFormUid, 'department', { + fieldType: 'picker', + titleField: 'location', + selectorFields: ['location'], + }); + let customPickerReadback = await getSurface(rootAgent, { + uid: customPickerField.fieldUid, + }); + expect(customPickerReadback.tree.props?.titleField).toBe('location'); + const configureCustomPickerOnlyRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: customPickerField.wrapperUid, + }, + changes: { + openMode: 'drawer', + }, + }, + }); + expect(configureCustomPickerOnlyRes.status).toBe(200); + customPickerReadback = await getSurface(rootAgent, { + uid: customPickerField.fieldUid, + }); + expect(customPickerReadback.tree.props?.titleField).toBe('location'); + expect( + _.castArray( + customPickerReadback.tree.subModels?.['grid-block']?.subModels?.items?.[0]?.subModels?.columns || [], + ).map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath), + ).toEqual(['location']); + + const pickerWithFieldsRes = await rootAgent.resource('flowSurfaces').addField({ + values: { + target: { + uid: formUid, + }, + fieldPath: 'roles', + fieldType: 'picker', + fields: ['title'], + }, + }); + expect(pickerWithFieldsRes.status).toBe(400); + expect(readErrorMessage(pickerWithFieldsRes)).toContain(`fieldType 'picker' does not support fields`); + + const subTableField = await addField(rootAgent, formUid, 'roles', { + fieldType: 'popupSubTable', + fields: ['title', 'name'], + }); + const configureSubTableOnlyRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: subTableField.wrapperUid, + }, + changes: { + pageSize: 7, + showIndex: true, + }, + }, + }); + expect(configureSubTableOnlyRes.status).toBe(200); + let subTableReadback = await getSurface(rootAgent, { + uid: subTableField.fieldUid, + }); + expect(subTableReadback.tree.props).toMatchObject({ + pageSize: 7, + showIndex: true, + }); + + const clearSubTableFieldsRes = await rootAgent.resource('flowSurfaces').configure({ + values: { + target: { + uid: subTableField.wrapperUid, + }, + changes: { + fields: [], + }, + }, + }); + expect(clearSubTableFieldsRes.status).toBe(200); + subTableReadback = await getSurface(rootAgent, { + uid: subTableField.fieldUid, + }); + expect( + _.castArray(subTableReadback.tree.subModels?.subTableColumns || []).filter( + (item: any) => item?.use === 'TableColumnModel', + ), + ).toEqual([]); + + const subTableWithSelectorFieldsRes = await rootAgent.resource('flowSurfaces').addField({ + values: { + target: { + uid: formUid, + }, + fieldPath: 'roles', + fieldType: 'popupSubTable', + selectorFields: ['title'], + }, + }); + expect(subTableWithSelectorFieldsRes.status).toBe(400); + expect(readErrorMessage(subTableWithSelectorFieldsRes)).toContain( + `fieldType 'popupSubTable' does not support selectorFields`, + ); + + const textWithFieldsRes = await rootAgent.resource('flowSurfaces').addField({ + values: { + target: { + uid: detailsUid, + }, + fieldPath: 'roles', + fieldType: 'text', + fields: ['title'], + }, + }); + expect(textWithFieldsRes.status).toBe(400); + expect(readErrorMessage(textWithFieldsRes)).toContain(`fieldType 'text' does not support fields`); + + const subFormField = await addField(rootAgent, employeeFormUid, 'department', { + fieldType: 'subForm', + titleField: 'title', + fields: ['location'], + }); + const subFormReadback = await getSurface(rootAgent, { + uid: subFormField.fieldUid, + }); + expect(subFormReadback.tree.use).toBe('SubFormFieldModel'); + expect( + _.castArray(subFormReadback.tree.subModels?.grid?.subModels?.items || []).map( + (item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath, + ), + ).toEqual(['location']); + + const detailsSubDetailsField = await addField(rootAgent, employeeDetailsUid, 'department', { + fieldType: 'subDetails', + titleField: 'title', + fields: ['location'], + }); + const subDetailsReadback = await getSurface(rootAgent, { + uid: detailsSubDetailsField.fieldUid, + }); + expect(subDetailsReadback.tree.use).toBe('DisplaySubItemFieldModel'); + + const detailsSubDetailsListField = await addField(rootAgent, detailsUid, 'roles', { + fieldType: 'subDetailsList', + fields: ['title'], + }); + const subDetailsListReadback = await getSurface(rootAgent, { + uid: detailsSubDetailsListField.fieldUid, + }); + expect(subDetailsListReadback.tree.use).toBe('DisplaySubListFieldModel'); + + const formSubFormListField = await addField(rootAgent, formUid, 'roles', { + fieldType: 'subFormList', + fields: ['title'], + }); + const subFormListReadback = await getSurface(rootAgent, { + uid: formSubFormListField.fieldUid, + }); + expect(subFormListReadback.tree.use).toBe('SubFormListFieldModel'); + + const detailsTextField = await addField(rootAgent, detailsUid, 'roles', { + fieldType: 'text', + }); + const textReadback = await getSurface(rootAgent, { + uid: detailsTextField.fieldUid, + }); + expect(textReadback.tree.use).toBe('DisplayTextFieldModel'); + + const selectField = await addField(rootAgent, formUid, 'roles', { + fieldType: 'select', + }); + const selectReadback = await getSurface(rootAgent, { + uid: selectField.fieldUid, + }); + expect(selectReadback.tree.use).toBe('RecordSelectFieldModel'); + }); + it('should reject invalid openView uid through updateSettings stepParams writes', async () => { const page = await createPage(rootAgent, { title: 'Invalid updateSettings popup uid page', @@ -5670,17 +6181,15 @@ describe('flowSurfaces resource', () => { fieldUse: 'InputFieldModel', }); - const explicitFormField = await addField(rootAgent, createFormUid, 'nickname', { - fieldUse: formNicknameField.fieldUse, - }); - expect(explicitFormField.fieldUse).toBe(formNicknameField.fieldUse); + const inferredFormField = await addField(rootAgent, createFormUid, 'nickname'); + expect(inferredFormField.fieldUse).toBe(formNicknameField.fieldUse); - const explicitFormFieldReadback = await getSurface(rootAgent, { - uid: explicitFormField.fieldUid, + const inferredFormFieldReadback = await getSurface(rootAgent, { + uid: inferredFormField.fieldUid, }); - expect(explicitFormFieldReadback.tree.use).toBe(formNicknameField.fieldUse); + expect(inferredFormFieldReadback.tree.use).toBe(formNicknameField.fieldUse); - const mismatchedFieldUseRes = await rootAgent.resource('flowSurfaces').addField({ + const fieldUseRes = await rootAgent.resource('flowSurfaces').addField({ values: { target: { uid: createFormUid, @@ -5689,8 +6198,8 @@ describe('flowSurfaces resource', () => { fieldUse: tableNicknameField.fieldUse, }, }); - expect(mismatchedFieldUseRes.status).toBe(400); - expect(readErrorMessage(mismatchedFieldUseRes)).toContain(`does not match inferred fieldUse`); + expect(fieldUseRes.status).toBe(400); + expect(readErrorMessage(fieldUseRes)).toContain(`does not accept internal field keys: fieldUse`); const unknownFieldUseRes = await rootAgent.resource('flowSurfaces').addField({ values: { @@ -5702,11 +6211,10 @@ describe('flowSurfaces resource', () => { }, }); expect(unknownFieldUseRes.status).toBe(400); - expect(readErrorMessage(unknownFieldUseRes)).toContain(`is not allowed under`); + expect(readErrorMessage(unknownFieldUseRes)).toContain(`does not accept internal field keys: fieldUse`); const explicitFilterField = await addField(rootAgent, filterFormUid, 'nickname', { defaultTargetUid: tableUid, - fieldUse: filterNicknameField.fieldUse, }); expect(explicitFilterField.fieldUse).toBe(filterNicknameField.fieldUse); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/apply/compiler.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/apply/compiler.ts index 5c511f5f015..66b3282b8e3 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/apply/compiler.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/apply/compiler.ts @@ -28,6 +28,7 @@ import { import { FlowSurfaceBadRequestError } from '../errors'; import { CREATABLE_STANDALONE_FIELD_USES, FIELD_WRAPPER_USES } from '../node-use-sets'; import type { FlowSurfaceApplySpec, FlowSurfaceMutateOp, FlowSurfaceNodeSpec, FlowSurfaceWriteTarget } from '../types'; +import { getPublicFieldTypeForUse } from '../field-type-resolver'; import { didItemRefsChange, emitLayoutOp, @@ -795,6 +796,7 @@ function createFieldNode( _.get(innerField, ['stepParams', 'fieldSettings', 'init']) || _.get(desiredNode, ['stepParams', 'fieldSettings', 'init']); const defaultTargetUid = _.get(desiredNode, ['stepParams', 'filterFormItemSettings', 'init', 'defaultTargetUid']); + const fieldType = getPublicFieldTypeForUse(innerField?.stepParams?.fieldBinding?.use || innerField?.use); if (!fieldInit?.fieldPath) { throw new FlowSurfaceBadRequestError( `flowSurfaces apply field '${desiredNode.use}' requires stepParams.fieldSettings.init.fieldPath`, @@ -815,7 +817,7 @@ function createFieldNode( dataSourceKey: fieldInit.dataSourceKey, collectionName: fieldInit.collectionName, ...(requestedRenderer ? { renderer: requestedRenderer } : {}), - ...(fieldCapability.fieldUse ? { fieldUse: fieldCapability.fieldUse } : {}), + ...(fieldType ? { fieldType } : {}), ...(defaultTargetUid ? { defaultTargetUid } : {}), ...(popup ? { popup } : {}), }, diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/approval/README.md b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/approval/README.md index 16988030656..c2707ba24e6 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/approval/README.md +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/approval/README.md @@ -83,16 +83,16 @@ - `ApplyTaskCardDetailsItemModel` - `ApprovalTaskCardDetailsItemModel` -### Approval relation fieldComponent switching +### Approval relation fieldType switching - `PatternFormItemModel` - - 单值关系字段: `RecordSelectFieldModel` / `RecordPickerFieldModel` / `SubFormFieldModel` - - 多值关系字段: `RecordSelectFieldModel` / `RecordPickerFieldModel` / `SubFormListFieldModel` / `PatternSubTableFieldModel` + - 单值关系字段: `text` / `picker` / `subForm` + - 多值关系字段: `text` / `picker` / `subFormList` / `subTable` / `popupSubTable` - 指向 file collection 时,继续保留推导出的默认 binding use,不公开 nested subform / subtable 切换 - `ApprovalDetailsItemModel` / `ApplyTaskCardDetailsItemModel` / `ApprovalTaskCardDetailsItemModel` - - 单值关系字段: `DisplayTextFieldModel` / `DisplaySubItemFieldModel` - - 多值关系字段: `DisplayTextFieldModel` / `DisplaySubListFieldModel` / `DisplaySubTableFieldModel` -- `catalog.node.configureOptions.fieldComponent.enum` 会按当前 wrapper use 与真实字段 interface 动态收窄,`configure(fieldComponent)` 也按同一套 truth source 校验 + - 单值关系字段: `text` / `subDetails` + - 多值关系字段: `text` / `subDetailsList` / `subTable` +- `catalog.node.configureOptions.fieldType.enum` 会按当前 wrapper use 与真实字段 interface 动态收窄,`configure(fieldType)` 也按同一套 truth source 校验 ## 关键约束 @@ -105,7 +105,7 @@ - approval actions 只应出现在各自 block 的 action 容器中。 - approval singleton actions 在当前表单已经存在时,不应继续出现在 catalog 中。 - `PatternFormItemModel` 下的 inner field 必须持久化为 `PatternFormFieldModel`,并把真实字段 use 记录到 `stepParams.fieldBinding.use`。 -- approval relation `fieldComponent` 的公开切换范围必须跟随真实字段 interface 与 wrapper use 动态收窄,不能再靠静态全局枚举推断。 +- approval relation `fieldType` 的公开切换范围必须跟随真实字段 interface 与 wrapper use 动态收窄,不能再靠静态全局枚举推断。 - approval details / task-card details 复用 details 语义,但保留自己的 wrapper use,以便 client 侧继续走审批专属的模型逻辑。 ## 对外 API 策略 diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/association-interfaces.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/association-interfaces.ts new file mode 100644 index 00000000000..cefc966b038 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/association-interfaces.ts @@ -0,0 +1,12 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export const SINGLE_VALUE_ASSOCIATION_INTERFACES = new Set(['m2o', 'o2o', 'oho', 'obo', 'updatedBy', 'createdBy']); + +export const MULTI_VALUE_ASSOCIATION_INTERFACES = new Set(['m2m', 'o2m', 'mbm']); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-blocks.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-blocks.ts index 98d015703b8..a6fc7cb4cc4 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-blocks.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/compile-blocks.ts @@ -50,6 +50,11 @@ import { readOptionalString, readString, } from './private-utils'; +import { + assertNoInternalFieldKeys, + normalizePublicFieldNameList, + normalizePublicFieldType, +} from '../field-type-resolver'; type FlowSurfaceCompiledBlocks = { blocks: any[]; @@ -76,6 +81,7 @@ const APPLY_BLUEPRINT_BLOCK_TYPE_ENUM = [ 'chart', 'actionPanel', 'jsBlock', + 'tree', ] as const; const APPLY_BLUEPRINT_BLOCK_ALLOWED_KEYS = [ @@ -106,6 +112,14 @@ const APPLY_BLUEPRINT_FIELD_ALLOWED_KEYS = [ 'associationPathName', 'renderer', 'type', + 'fieldType', + 'fields', + 'selectorFields', + 'titleField', + 'openMode', + 'popupSize', + 'pageSize', + 'showIndex', 'label', 'target', 'settings', @@ -219,6 +233,24 @@ function assertApplyBlueprintKanbanMainContent(block: Record, conte } } +function assertApplyBlueprintTreeMainContent(block: Record, context: string) { + if (readOptionalString(block.type) !== 'tree') { + return; + } + if (Object.prototype.hasOwnProperty.call(block, 'fields')) { + throwBadRequest(`${context}.fields is not supported on tree blocks`); + } + if (Object.prototype.hasOwnProperty.call(block, 'fieldGroups')) { + throwBadRequest(`${context}.fieldGroups is not supported on tree blocks`); + } + if (Object.prototype.hasOwnProperty.call(block, 'actions')) { + throwBadRequest(`${context}.actions is not supported on tree blocks`); + } + if (Object.prototype.hasOwnProperty.call(block, 'recordActions')) { + throwBadRequest(`${context}.recordActions is not supported on tree blocks`); + } +} + function assertApplyBlueprintBlockType(type: string | undefined, context: string) { if (!type) { return; @@ -974,8 +1006,16 @@ function compileField( throwBadRequest(`${context}[${index}] must be a string or object`); } assertOnlyAllowedKeys(input, `${context}[${index}]`, APPLY_BLUEPRINT_FIELD_ALLOWED_KEYS); + assertNoInternalFieldKeys(input, `${context}[${index}]`); + assertNoInternalFieldKeys(input.settings, `${context}[${index}].settings`); const fieldPath = readOptionalString(input.field); const syntheticType = readOptionalString(input.type); + const fieldType = normalizePublicFieldType((input as any).fieldType, `${context}[${index}]`); + const fields = normalizePublicFieldNameList((input as any).fields, `${context}[${index}].fields`); + const selectorFields = normalizePublicFieldNameList( + (input as any).selectorFields, + `${context}[${index}].selectorFields`, + ); if (!fieldPath && !syntheticType) { throwBadRequest(`${context}[${index}] requires field or type`); } @@ -1003,6 +1043,14 @@ function compileField( associationPathName: readOptionalString(input.associationPathName), renderer: readOptionalString(input.renderer), type: syntheticType, + fieldType, + fields, + selectorFields, + titleField: readOptionalString((input as any).titleField), + openMode: readOptionalString((input as any).openMode), + popupSize: readOptionalString((input as any).popupSize), + pageSize: (input as any).pageSize, + showIndex: (input as any).showIndex, target: resolveTargetBlockKey(input.target, localBlockKeys, `${context}[${index}].target`), settings: Object.keys(settings).length ? settings : undefined, popup, @@ -1094,6 +1142,7 @@ function compileBlocks( } assertApplyBlueprintCalendarMainContent(block, `${context}[${index}]`); assertApplyBlueprintKanbanMainContent(block, `${context}[${index}]`); + assertApplyBlueprintTreeMainContent(block, `${context}[${index}]`); const fields = resolveBlockFieldInputs(block, `${context}[${index}]`); fields.forEach((field: any, fieldIndex: number) => { if (typeof field?.target !== 'string' || !field.target.trim()) { @@ -1115,6 +1164,7 @@ function compileBlocks( assertApplyBlueprintBlockType(readOptionalString(block.type), `${context}[${index}]`); assertApplyBlueprintCalendarMainContent(block, `${context}[${index}]`); assertApplyBlueprintKanbanMainContent(block, `${context}[${index}]`); + assertApplyBlueprintTreeMainContent(block, `${context}[${index}]`); const explicitKey = readString(block.key); const fallback = block.type ? `${block.type}_${index + 1}` : `block_${index + 1}`; const localKey = normalizeBlueprintLocalKey(block.key, fallback, `${context}[${index}].key`); @@ -1217,10 +1267,14 @@ function compileBlocks( resource: buildBlockResource(block, blockContext), template, settings: Object.keys(settings).length ? settings : undefined, - fields: blockType === 'calendar' ? undefined : fields, - fieldsLayout: blockType === 'calendar' || blockType === 'kanban' ? undefined : fieldsLayout, - actions: actionsWithDefaultFilter, - recordActions: blockType === 'calendar' || blockType === 'kanban' ? undefined : mergedActions.recordActions, + fields: blockType === 'calendar' || blockType === 'tree' ? undefined : fields, + fieldsLayout: + blockType === 'calendar' || blockType === 'kanban' || blockType === 'tree' ? undefined : fieldsLayout, + actions: blockType === 'tree' ? undefined : actionsWithDefaultFilter, + recordActions: + blockType === 'calendar' || blockType === 'kanban' || blockType === 'tree' + ? undefined + : mergedActions.recordActions, }); }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts index 331127b611e..0827dfdd000 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/blueprint/public-types.ts @@ -109,6 +109,14 @@ export type FlowSurfaceApplyBlueprintFieldObjectSpec = { associationPathName?: string; renderer?: string; type?: string; + fieldType?: string; + fields?: string[]; + selectorFields?: string[]; + titleField?: string; + openMode?: string; + popupSize?: string; + pageSize?: number; + showIndex?: boolean; label?: string; target?: string; settings?: Record; @@ -181,7 +189,8 @@ export type FlowSurfaceApplyBlueprintBlockType = | 'iframe' | 'chart' | 'actionPanel' - | 'jsBlock'; + | 'jsBlock' + | 'tree'; export type FlowSurfaceApplyBlueprintBlockSpec = { key?: string; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts index 9bea6cc494a..266534dc7d6 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/builder.ts @@ -656,6 +656,7 @@ export function buildFieldTree(params: BuildFieldParams) { }, }), ), + ...(fieldDefaults.subModels ? { subModels: _.cloneDeep(fieldDefaults.subModels) } : {}), }, }, }, @@ -1089,7 +1090,7 @@ function inferActionDefaultProps(use: string, scope?: FlowSurfaceCatalogItem['sc } function applyContainerActionStyle(props: Record, containerUse?: string) { - if (containerUse === 'TableActionsColumnModel') { + if (['TableActionsColumnModel', 'ListItemModel', 'GridCardItemModel'].includes(String(containerUse || '').trim())) { return { ...props, type: 'link', @@ -1145,11 +1146,40 @@ function buildBlockDefaults(use: string): FlowSurfaceNodeDefaults { }, }; } + if (use === 'TreeBlockModel') { + return { + props: { + searchable: true, + defaultExpandAll: false, + includeDescendants: true, + }, + }; + } return {}; } -function getStandaloneFieldDefaults(use: string): FlowSurfaceNodeDefaults { +export function getStandaloneFieldDefaults(use: string): FlowSurfaceNodeDefaults { switch (use) { + case 'PopupSubTableFieldModel': + return { + subModels: { + subTableColumns: [ + { + use: 'PopupSubTableActionsColumnModel', + subModels: { + actions: [ + { + use: 'PopupSubTableEditActionModel', + }, + { + use: 'PopupSubTableRemoveActionModel', + }, + ], + }, + }, + ], + }, + }; case 'JSFieldModel': return { stepParams: { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/catalog.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/catalog.ts index e384655675d..9745f27524c 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/catalog.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/catalog.ts @@ -50,13 +50,10 @@ import { TABLE_ROW_ACTION_CONTAINER_USES, } from './action-scope'; import { FlowSurfaceBadRequestError, FlowSurfaceInternalError } from './errors'; -import { - MULTI_VALUE_ASSOCIATION_INTERFACES, - normalizeFieldContainerKind, - shouldUseAssociationTitleTextDisplay, -} from './field-semantics'; +import { normalizeFieldContainerKind, shouldUseAssociationTitleTextDisplay } from './field-semantics'; import { inferSharedFieldDefaultBindingUse } from './core-field-default-bindings'; import { getRegisteredFieldUses, resolveRegisteredFieldBinding } from './field-binding-registry'; +import { MULTI_VALUE_ASSOCIATION_INTERFACES, SINGLE_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; import { getFieldInterface, resolveFieldTargetCollection } from './service-helpers'; import { FLOW_SURFACE_BLOCK_SUPPORT_MATRIX } from './support-matrix'; @@ -334,6 +331,36 @@ const CALENDAR_SETTINGS_GROUP = { 'linkageRules.value': ARRAY_SCHEMA, }, }; +const TREE_BLOCK_PROP_SCHEMAS = { + searchable: BOOLEAN_SCHEMA, + defaultExpandAll: BOOLEAN_SCHEMA, + includeDescendants: BOOLEAN_SCHEMA, + fieldNames: OBJECT_SCHEMA, + pageSize: NUMBER_SCHEMA, +}; +const TREE_SETTINGS_GROUP = { + allowedPaths: [ + 'searchable.searchable', + 'defaultExpandAll.defaultExpandAll', + 'includeDescendants.includeDescendants', + 'titleField.titleField', + 'pageSize.pageSize', + 'dataScope.filter', + 'defaultSorting.sort', + ], + clearable: true, + mergeStrategy: 'deep' as const, + eventBindingSteps: ['treeSettings', 'dataScope', 'defaultSorting'], + pathSchemas: { + 'searchable.searchable': BOOLEAN_SCHEMA, + 'defaultExpandAll.defaultExpandAll': BOOLEAN_SCHEMA, + 'includeDescendants.includeDescendants': BOOLEAN_SCHEMA, + 'titleField.titleField': STRING_SCHEMA, + 'pageSize.pageSize': NUMBER_SCHEMA, + 'dataScope.filter': FILTER_GROUP_SCHEMA, + 'defaultSorting.sort': ARRAY_SCHEMA, + }, +}; const KANBAN_SETTINGS_GROUP = { allowedPaths: [ 'grouping.groupField', @@ -437,6 +464,7 @@ const REGISTERED_FILTER_FIELD_USE_SET = getRegisteredFieldUses('filter'); const EDITABLE_FIELD_USE_SET = new Set([ ...JS_EDITABLE_FIELD_USE_SET, 'RecordSelectFieldModel', + 'RecordPickerFieldModel', 'JsonFieldModel', 'TextareaFieldModel', 'IconFieldModel', @@ -455,10 +483,14 @@ const EDITABLE_FIELD_USE_SET = new Set([ 'CollectionSelectorFieldModel', 'RichTextFieldModel', 'InputFieldModel', + 'SubFormListFieldModel', + 'SubTableFieldModel', + 'PopupSubTableFieldModel', ]); const DISPLAY_FIELD_USE_SET = new Set([ ...JS_DISPLAY_FIELD_USE_SET, 'DisplaySubItemFieldModel', + 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel', 'DisplayHtmlFieldModel', 'DisplayNumberFieldModel', @@ -490,7 +522,6 @@ const APPROVAL_DETAILS_FIELD_COMPONENT_WRAPPER_USE_SET = new Set([ 'ApplyTaskCardDetailsItemModel', 'ApprovalTaskCardDetailsItemModel', ]); -const SINGLE_VALUE_ASSOCIATION_INTERFACES = new Set(['m2o', 'o2o', 'oho', 'obo', 'updatedBy', 'createdBy']); const KNOWN_FIELD_NODE_USES = new Set([ ...EDITABLE_FIELD_USE_SET, ...DISPLAY_FIELD_USE_SET, @@ -985,6 +1016,28 @@ CALENDAR_BLOCK_CONTRACT.domains.stepParams = groupedDomain({ cardSettings: BLOCK_CARD_SETTINGS_GROUP, }); +const TREE_BLOCK_CONTRACT = createContract({ + editableDomains: ['props', 'decoratorProps', 'stepParams', 'flowRegistry'], + props: ['searchable', 'defaultExpandAll', 'includeDescendants', 'fieldNames', 'pageSize'], + decoratorProps: ['height', 'heightMode'], + stepParams: ['resourceSettings', 'treeSettings', 'cardSettings'], + flowRegistry: true, + eventCapabilities: { + direct: DEFAULT_DIRECT_EVENTS, + object: ['click'], + }, +}); +TREE_BLOCK_CONTRACT.domains.props = keyedDomain( + ['searchable', 'defaultExpandAll', 'includeDescendants', 'fieldNames', 'pageSize'], + 'deep', + TREE_BLOCK_PROP_SCHEMAS, +); +TREE_BLOCK_CONTRACT.domains.stepParams = groupedDomain({ + resourceSettings: RESOURCE_SETTINGS_GROUP, + treeSettings: TREE_SETTINGS_GROUP, + cardSettings: BLOCK_CARD_SETTINGS_GROUP, +}); + const KANBAN_BLOCK_CONTRACT = createContract({ editableDomains: ['props', 'decoratorProps', 'stepParams', 'flowRegistry'], props: [ @@ -2494,6 +2547,7 @@ const NODE_CONTRACT_ENTRIES: Array<[string, FlowSurfaceNodeContract]> = [ ['ApprovalBlockGridModel', GRID_NODE_CONTRACT], ['TableBlockModel', TABLE_BLOCK_CONTRACT], ['CalendarBlockModel', CALENDAR_BLOCK_CONTRACT], + ['TreeBlockModel', TREE_BLOCK_CONTRACT], ['KanbanBlockModel', KANBAN_BLOCK_CONTRACT], ['CreateFormModel', CREATE_FORM_BLOCK_CONTRACT], ['EditFormModel', EDIT_FORM_BLOCK_CONTRACT], @@ -2670,7 +2724,7 @@ function getAllowedFieldUseSet(containerUse?: string, enabledPackages?: Readonly } } -function canUseNestedApprovalAssociationFieldComponent(input: { +function canUseNestedAssociationFieldComponent(input: { field?: any; dataSourceKey?: string; getCollection?: (dataSourceKey: string, collectionName: string) => any; @@ -2696,7 +2750,10 @@ export function getSupportedFieldComponentUseSet(input: { return baseAllowedFieldUses; } - const wrapperUse = getApprovalFieldWrapperUse(input.containerUse) || String(input.containerUse || '').trim(); + const wrapperUse = + getApprovalFieldWrapperUse(input.containerUse) || + getFieldWrapperUseForContainer(input.containerUse) || + String(input.containerUse || '').trim(); const inferredFieldUse = inferFieldUseByContainer(input.containerUse, input.field, { enabledPackages: input.enabledPackages, dataSourceKey: input.dataSourceKey, @@ -2709,7 +2766,7 @@ export function getSupportedFieldComponentUseSet(input: { [ 'RecordSelectFieldModel', 'RecordPickerFieldModel', - canUseNestedApprovalAssociationFieldComponent(input) ? 'SubFormFieldModel' : undefined, + canUseNestedAssociationFieldComponent(input) ? 'SubFormFieldModel' : undefined, inferredFieldUse, ].filter(Boolean), ); @@ -2719,8 +2776,34 @@ export function getSupportedFieldComponentUseSet(input: { [ 'RecordSelectFieldModel', 'RecordPickerFieldModel', - canUseNestedApprovalAssociationFieldComponent(input) ? 'SubFormListFieldModel' : undefined, - canUseNestedApprovalAssociationFieldComponent(input) ? 'PatternSubTableFieldModel' : undefined, + canUseNestedAssociationFieldComponent(input) ? 'SubFormListFieldModel' : undefined, + canUseNestedAssociationFieldComponent(input) ? 'SubTableFieldModel' : undefined, + canUseNestedAssociationFieldComponent(input) ? 'PopupSubTableFieldModel' : undefined, + inferredFieldUse, + ].filter(Boolean), + ); + } + } + + if (wrapperUse === 'FormItemModel') { + if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return new Set( + [ + 'RecordSelectFieldModel', + 'RecordPickerFieldModel', + canUseNestedAssociationFieldComponent(input) ? 'SubFormFieldModel' : undefined, + inferredFieldUse, + ].filter(Boolean), + ); + } + if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return new Set( + [ + 'RecordSelectFieldModel', + 'RecordPickerFieldModel', + canUseNestedAssociationFieldComponent(input) ? 'SubFormListFieldModel' : undefined, + canUseNestedAssociationFieldComponent(input) ? 'SubTableFieldModel' : undefined, + canUseNestedAssociationFieldComponent(input) ? 'PopupSubTableFieldModel' : undefined, inferredFieldUse, ].filter(Boolean), ); @@ -2740,6 +2823,36 @@ export function getSupportedFieldComponentUseSet(input: { } } + if (wrapperUse === 'DetailsItemModel') { + if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return new Set(['DisplayTextFieldModel', 'DisplaySubItemFieldModel', inferredFieldUse].filter(Boolean)); + } + if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return new Set( + ['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel', inferredFieldUse].filter( + Boolean, + ), + ); + } + } + + if (wrapperUse === 'FormAssociationItemModel' || wrapperUse === 'TableColumnModel') { + if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return new Set(['DisplayTextFieldModel', inferredFieldUse].filter(Boolean)); + } + if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return new Set( + ['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel', inferredFieldUse].filter( + Boolean, + ), + ); + } + } + + if (inferredFieldUse) { + return new Set([inferredFieldUse]); + } + return baseAllowedFieldUses; } @@ -2834,6 +2947,7 @@ export function resolveSupportedFieldCapability(input: { containerUse: string; field?: any; requestedFieldUse?: string; + requestedFieldUseMode?: 'fieldUse' | 'fieldType'; requestedWrapperUse?: string; allowUnresolvedFieldUse?: boolean; requestedRenderer?: string; @@ -2911,6 +3025,7 @@ export function resolveSupportedFieldCapability(input: { input.requestedFieldUse && inferredFieldUse && input.requestedFieldUse !== inferredFieldUse && + input.requestedFieldUseMode !== 'fieldType' && KNOWN_FIELD_NODE_USES.has(input.requestedFieldUse) ) { throw new FlowSurfaceBadRequestError( @@ -2918,7 +3033,16 @@ export function resolveSupportedFieldCapability(input: { ); } - const allowedFieldUses = getAllowedFieldUseSet(input.containerUse, input.enabledPackages); + const allowedFieldUses = + input.requestedFieldUseMode === 'fieldType' && input.field + ? getSupportedFieldComponentUseSet({ + containerUse: input.containerUse, + field: input.field, + enabledPackages: input.enabledPackages, + dataSourceKey: input.dataSourceKey, + getCollection: input.getCollection, + }) + : getAllowedFieldUseSet(input.containerUse, input.enabledPackages); if (!allowedFieldUses?.has(fieldUse)) { throw new FlowSurfaceBadRequestError( `flowSurfaces fieldUse '${fieldUse}' is not allowed under '${input.containerUse}'`, @@ -3007,6 +3131,7 @@ export function getAvailableActionCatalogItems( const COLLECTION_RESOURCE_REQUIRED = new Set([ 'TableBlockModel', 'CalendarBlockModel', + 'TreeBlockModel', 'KanbanBlockModel', 'CreateFormModel', 'EditFormModel', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/compose-compiler.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/compose-compiler.ts index a881da93116..d1c2f6ac467 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/compose-compiler.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/compose-compiler.ts @@ -34,6 +34,14 @@ export type FlowSurfaceComposeNormalizedFieldSpec = { associationPathName?: string; renderer?: string; type?: string; + fieldType?: string; + fields?: string[]; + selectorFields?: string[]; + titleField?: string; + openMode?: string; + popupSize?: string; + pageSize?: number; + showIndex?: boolean; target?: string | Record; settings?: FlowSurfaceComposeObject; popup?: FlowSurfaceComposeObject; @@ -243,6 +251,14 @@ function buildComposeFieldCreatePayload(fieldSpec: FlowSurfaceComposeNormalizedF ...(fieldSpec.associationPathName ? { associationPathName: fieldSpec.associationPathName } : {}), ...(fieldSpec.renderer ? { renderer: fieldSpec.renderer } : {}), ...(fieldSpec.type ? { type: fieldSpec.type } : {}), + ...(fieldSpec.fieldType ? { fieldType: fieldSpec.fieldType } : {}), + ...(typeof fieldSpec.fields !== 'undefined' ? { fields: fieldSpec.fields } : {}), + ...(typeof fieldSpec.selectorFields !== 'undefined' ? { selectorFields: fieldSpec.selectorFields } : {}), + ...(fieldSpec.titleField ? { titleField: fieldSpec.titleField } : {}), + ...(fieldSpec.openMode ? { openMode: fieldSpec.openMode } : {}), + ...(fieldSpec.popupSize ? { popupSize: fieldSpec.popupSize } : {}), + ...(typeof fieldSpec.pageSize !== 'undefined' ? { pageSize: fieldSpec.pageSize } : {}), + ...(typeof fieldSpec.showIndex !== 'undefined' ? { showIndex: fieldSpec.showIndex } : {}), ...(fieldSpec.popup ? { popup: fieldSpec.popup } : {}), ...(fieldSpec.__autoPopupForRelationField ? { __autoPopupForRelationField: true } : {}), ...(fieldSpec[FLOW_SURFACE_APPLY_BLUEPRINT_POPUP_DEFAULTS_KEY] diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/configure-options.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/configure-options.ts index d391b9740b0..78b4592b933 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/configure-options.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/configure-options.ts @@ -14,6 +14,7 @@ import type { FlowSurfaceContainerKind, } from './types'; import { normalizeApprovalSemanticUse } from './approval'; +import { FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES } from './field-type-resolver'; const stringOption = ( description?: string, @@ -96,10 +97,29 @@ const DISPLAY_STYLE = stringOption('Display style', { example: 'tag', }); -const FIELD_COMPONENT = stringOption('Field component model use', { - example: 'DisplayTextFieldModel', +const FIELD_TYPE = stringOption('Public relation field presentation type', { + enum: [...FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES], + example: 'popupSubTable', }); +const RELATION_FIELDS = arrayOption('Relation target fields', { + example: ['title', 'name'], +}); + +const SELECTOR_FIELDS = arrayOption('Record picker selector fields', { + example: ['title', 'code'], +}); + +const RELATION_FIELD_TYPE_OPTIONS: FlowSurfaceConfigureOptions = { + fieldType: FIELD_TYPE, + fields: RELATION_FIELDS, + selectorFields: SELECTOR_FIELDS, + openMode: stringOption('Popup open mode', { example: 'drawer' }), + popupSize: stringOption('Popup size', { example: 'medium' }), + pageSize: numberOption('Page size', { example: 10 }), + showIndex: booleanOption('Whether to show index', { example: true }), +}; + const CONFIRM = objectOption('Confirmation dialog configuration. You can also pass a boolean directly.', { example: { enable: true, @@ -241,6 +261,20 @@ const CALENDAR_OPTIONS: FlowSurfaceConfigureOptions = { eventPopup: OPEN_VIEW, }; +const TREE_OPTIONS: FlowSurfaceConfigureOptions = { + ...COMMON_BLOCK_HEADER_OPTIONS, + ...COMMON_HEIGHT_OPTIONS, + resource: COMMON_RESOURCE, + searchable: booleanOption('Whether search is enabled', { example: true }), + defaultExpandAll: booleanOption('Whether all tree nodes are expanded by default', { example: false }), + includeDescendants: booleanOption('Whether child nodes are included when filtering', { example: true }), + titleField: stringOption('Tree node title field', { example: 'title' }), + fieldNames: objectOption('Tree field names', { example: { title: 'title', key: 'id', children: 'children' } }), + pageSize: numberOption('Root records per page', { example: 200 }), + dataScope: FILTER_GROUP, + sorting: SORTING, +}; + const KANBAN_OPTIONS: FlowSurfaceConfigureOptions = { ...COMMON_BLOCK_HEADER_OPTIONS, ...COMMON_HEIGHT_OPTIONS, @@ -418,7 +452,7 @@ const TABLE_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = { editable: booleanOption('Whether editable', { example: false }), dataIndex: stringOption('Data index'), titleField: stringOption('Association display title field', { example: 'title' }), - fieldComponent: FIELD_COMPONENT, + ...RELATION_FIELD_TYPE_OPTIONS, clickToOpen: booleanOption('Whether clicking can open details', { example: true }), openView: OPEN_VIEW, code: JS_CODE, @@ -435,7 +469,7 @@ const DETAILS_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = { disabled: booleanOption('Whether disabled', { example: false }), pattern: stringOption('Display mode'), titleField: stringOption('Association display title field', { example: 'title' }), - fieldComponent: FIELD_COMPONENT, + ...RELATION_FIELD_TYPE_OPTIONS, labelWidth: stringOption('Label width', { example: '120px' }), labelWrap: booleanOption('Whether labels should wrap', { example: false }), clickToOpen: booleanOption('Whether clicking can open details', { example: true }), @@ -456,7 +490,7 @@ const FILTER_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = { allowMultiple: booleanOption('Whether multiple selection is allowed', { example: false }), maxCount: numberOption('Maximum count', { example: 5 }), name: stringOption('Field name override'), - fieldComponent: FIELD_COMPONENT, + ...RELATION_FIELD_TYPE_OPTIONS, labelWidth: stringOption('Label width', { example: '120px' }), labelWrap: booleanOption('Whether labels should wrap', { example: false }), clickToOpen: booleanOption('Whether clicking can open details', { example: false }), @@ -481,7 +515,7 @@ const FORM_FIELD_WRAPPER_OPTIONS: FlowSurfaceConfigureOptions = { pattern: stringOption('Input mode'), titleField: stringOption('Association display title field', { example: 'title' }), name: stringOption('Field name override'), - fieldComponent: FIELD_COMPONENT, + ...RELATION_FIELD_TYPE_OPTIONS, labelWidth: stringOption('Label width', { example: '120px' }), labelWrap: booleanOption('Whether labels should wrap', { example: false }), clickToOpen: booleanOption('Whether clicking can open details', { example: false }), @@ -810,6 +844,9 @@ export function getConfigureOptionsForUse(use?: string): FlowSurfaceConfigureOpt case 'CalendarBlockModel': options = cloneOptions(CALENDAR_OPTIONS); break; + case 'TreeBlockModel': + options = cloneOptions(TREE_OPTIONS); + break; case 'KanbanBlockModel': options = cloneOptions(KANBAN_OPTIONS); break; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/default-action-popup.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/default-action-popup.ts index e4920a0141d..eebd96c25e6 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/default-action-popup.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/default-action-popup.ts @@ -8,7 +8,7 @@ */ import _ from 'lodash'; -import { MULTI_VALUE_ASSOCIATION_INTERFACES } from './field-semantics'; +import { MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; import { getFieldInterface, getFieldName, getFieldType } from './service-helpers'; export type FlowSurfaceDefaultActionPopupType = 'addNew' | 'view' | 'edit'; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-binding-registry.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-binding-registry.ts index 092139ecfb6..cf27e83ad5d 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-binding-registry.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-binding-registry.ts @@ -7,9 +7,12 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { MULTI_VALUE_ASSOCIATION_INTERFACES, SINGLE_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; import { normalizeFieldContainerKind } from './field-semantics'; import { getFieldInterface, resolveFieldTargetCollection } from './service-helpers'; +export { MULTI_VALUE_ASSOCIATION_INTERFACES, SINGLE_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; + export type FlowSurfaceFieldBindingScope = 'display' | 'editable' | 'filter'; type FlowSurfaceFieldBindingRule = { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-semantics.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-semantics.ts index bd8f0d5ed68..c17a3342908 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-semantics.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-semantics.ts @@ -8,6 +8,9 @@ */ import { normalizeApprovalSemanticUse } from './approval'; +import { MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; + +export { MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; const FORM_FIELD_CONTAINER_USES = new Set([ 'FormBlockModel', @@ -35,8 +38,6 @@ const FILTER_FIELD_CONTAINER_USES = new Set(['FilterFormBlockModel', 'FilterForm const TABLE_FIELD_CONTAINER_USES = new Set(['TableBlockModel', 'TableColumnModel']); -export const MULTI_VALUE_ASSOCIATION_INTERFACES = new Set(['m2m', 'o2m', 'mbm']); - export function normalizeFieldContainerKind(containerUse?: string) { const normalized = normalizeApprovalSemanticUse(containerUse); if (FORM_FIELD_CONTAINER_USES.has(normalized)) { diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-type-resolver.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-type-resolver.ts new file mode 100644 index 00000000000..a9448cef875 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/field-type-resolver.ts @@ -0,0 +1,321 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import _ from 'lodash'; +import { FlowSurfaceBadRequestError, throwBadRequest } from './errors'; +import { SINGLE_VALUE_ASSOCIATION_INTERFACES, MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; +import { normalizeFieldContainerKind } from './field-semantics'; +import { + getCollectionFields, + getFieldInterface, + getFieldName, + isAssociationField, + resolveFieldFromCollection, + resolveFieldTargetCollection, +} from './service-helpers'; +import { resolveCollectionSafeTitleField } from './association-title-field'; + +export const FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES = [ + 'text', + 'select', + 'picker', + 'subForm', + 'subFormList', + 'subDetails', + 'subDetailsList', + 'subTable', + 'popupSubTable', +] as const; + +export type FlowSurfacePublicRelationFieldType = (typeof FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES)[number]; + +export const FLOW_SURFACE_RESERVED_RELATION_FIELD_TYPES = ['cascade', 'upload', 'preview'] as const; + +export const FLOW_SURFACE_PUBLIC_FIELD_TYPE_SET = new Set([ + ...FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES, + ...FLOW_SURFACE_RESERVED_RELATION_FIELD_TYPES, +]); + +export const FLOW_SURFACE_INTERNAL_FIELD_KEYS = [ + 'fieldComponent', + 'fieldModel', + 'componentFields', + 'use', + 'fieldUse', + 'subModels', + 'props', + 'stepParams', +]; + +const FIELD_USE_TO_PUBLIC_FIELD_TYPE: Record = { + DisplayTextFieldModel: 'text', + RecordSelectFieldModel: 'select', + RecordPickerFieldModel: 'picker', + SubFormFieldModel: 'subForm', + SubFormListFieldModel: 'subFormList', + DisplaySubItemFieldModel: 'subDetails', + DisplaySubListFieldModel: 'subDetailsList', + DisplaySubTableFieldModel: 'subTable', + SubTableFieldModel: 'subTable', + PopupSubTableFieldModel: 'popupSubTable', +}; + +const FIELD_TYPES_WITH_FIELDS = new Set([ + 'subForm', + 'subFormList', + 'subDetails', + 'subDetailsList', + 'subTable', + 'popupSubTable', +]); + +const FIELD_TYPES_WITH_SELECTOR_FIELDS = new Set(['picker']); + +const FIELD_TYPES_WITH_TABLE_PROPS = new Set(['subTable', 'popupSubTable']); + +export function assertNoInternalFieldKeys(input: Record | undefined, context: string) { + if (!_.isPlainObject(input)) { + return; + } + const forbidden = FLOW_SURFACE_INTERNAL_FIELD_KEYS.filter((key) => Object.prototype.hasOwnProperty.call(input, key)); + if (forbidden.length) { + throwBadRequest(`${context} does not accept internal field keys: ${forbidden.join(', ')}`); + } +} + +export function normalizePublicFieldType(value: any, context: string): FlowSurfacePublicRelationFieldType | undefined { + if (_.isUndefined(value) || _.isNull(value) || value === '') { + return undefined; + } + const normalized = String(value || '').trim(); + if (!normalized) { + return undefined; + } + if (FLOW_SURFACE_RESERVED_RELATION_FIELD_TYPES.includes(normalized as any)) { + throwBadRequest(`flowSurfaces ${context} fieldType '${normalized}' is reserved for plugin-specific field types`); + } + if (!FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES.includes(normalized as any)) { + throwBadRequest( + `flowSurfaces ${context} fieldType '${normalized}' is not supported; supported fieldTypes: ${FLOW_SURFACE_PUBLIC_RELATION_FIELD_TYPES.join( + ', ', + )}`, + ); + } + return normalized as FlowSurfacePublicRelationFieldType; +} + +export function normalizePublicFieldNameList(value: any, context: string): string[] | undefined { + if (_.isUndefined(value)) { + return undefined; + } + if (!Array.isArray(value)) { + throwBadRequest(`flowSurfaces ${context} must be an array of field names`); + } + return value.map((item, index) => { + const normalized = String(item || '').trim(); + if (!normalized) { + throwBadRequest(`flowSurfaces ${context}[${index + 1}] cannot be empty`); + } + return normalized; + }); +} + +export function getPublicFieldTypeForUse(use?: string): FlowSurfacePublicRelationFieldType | undefined { + return FIELD_USE_TO_PUBLIC_FIELD_TYPE[String(use || '').trim()]; +} + +function getAssociationCardinality(field: any): 'single' | 'multi' | null { + const fieldInterface = String(getFieldInterface(field) || '').trim(); + if (SINGLE_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return 'single'; + } + if (MULTI_VALUE_ASSOCIATION_INTERFACES.has(fieldInterface)) { + return 'multi'; + } + return null; +} + +function pickCollectionFallbackFieldName(collection: any) { + const safeTitleFieldName = resolveCollectionSafeTitleField(collection)?.fieldName; + if (safeTitleFieldName && resolveFieldFromCollection(collection, safeTitleFieldName)) { + return safeTitleFieldName; + } + const primaryField = getCollectionFields(collection).find( + (field) => !!field?.primaryKey || !!field?.options?.primaryKey, + ); + return getFieldName(primaryField) || 'id'; +} + +function assertTargetFieldNamesExist(targetCollection: any, fields: string[], context: string) { + fields.forEach((fieldName) => { + if (!resolveFieldFromCollection(targetCollection, fieldName)) { + throwBadRequest(`flowSurfaces ${context} field '${fieldName}' does not exist on relation target collection`); + } + }); +} + +export function resolveRelationFieldType(input: { + fieldType: any; + containerUse: string; + field: any; + dataSourceKey: string; + getCollection: (dataSourceKey: string, collectionName: string) => any; + fields?: any; + selectorFields?: any; + titleField?: any; + openMode?: any; + popupSize?: any; + pageSize?: any; + showIndex?: any; + applyDefaults?: boolean; + context: string; +}) { + const fieldType = normalizePublicFieldType(input.fieldType, input.context); + if (!fieldType) { + return undefined; + } + if (!isAssociationField(input.field)) { + throw new FlowSurfaceBadRequestError('flowSurfaces fieldType is only supported for relation fields'); + } + + const targetCollection = resolveFieldTargetCollection( + input.field, + input.dataSourceKey || 'main', + input.getCollection, + ); + if (!targetCollection) { + throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' cannot resolve relation target collection`); + } + + const cardinality = getAssociationCardinality(input.field); + if (!cardinality) { + throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' is not supported for this relation field`); + } + + const containerKind = normalizeFieldContainerKind(input.containerUse); + let fieldUse: string | undefined; + switch (fieldType) { + case 'text': + fieldUse = containerKind === 'form' ? 'RecordSelectFieldModel' : 'DisplayTextFieldModel'; + break; + case 'select': + fieldUse = 'RecordSelectFieldModel'; + break; + case 'picker': + fieldUse = 'RecordPickerFieldModel'; + break; + case 'subForm': + if (cardinality !== 'single') { + throwBadRequest(`flowSurfaces ${input.context} fieldType 'subForm' only supports single relation fields`); + } + fieldUse = 'SubFormFieldModel'; + break; + case 'subFormList': + if (cardinality !== 'multi') { + throwBadRequest(`flowSurfaces ${input.context} fieldType 'subFormList' only supports multi relation fields`); + } + fieldUse = 'SubFormListFieldModel'; + break; + case 'subDetails': + if (cardinality !== 'single') { + throwBadRequest(`flowSurfaces ${input.context} fieldType 'subDetails' only supports single relation fields`); + } + fieldUse = 'DisplaySubItemFieldModel'; + break; + case 'subDetailsList': + if (cardinality !== 'multi') { + throwBadRequest(`flowSurfaces ${input.context} fieldType 'subDetailsList' only supports multi relation fields`); + } + fieldUse = 'DisplaySubListFieldModel'; + break; + case 'subTable': + if (cardinality !== 'multi') { + throwBadRequest(`flowSurfaces ${input.context} fieldType 'subTable' only supports multi relation fields`); + } + fieldUse = containerKind === 'form' ? 'SubTableFieldModel' : 'DisplaySubTableFieldModel'; + break; + case 'popupSubTable': + if (cardinality !== 'multi') { + throwBadRequest(`flowSurfaces ${input.context} fieldType 'popupSubTable' only supports multi relation fields`); + } + fieldUse = 'PopupSubTableFieldModel'; + break; + } + + const explicitFields = normalizePublicFieldNameList(input.fields, `${input.context}.fields`); + const explicitSelectorFields = normalizePublicFieldNameList(input.selectorFields, `${input.context}.selectorFields`); + if (explicitFields && explicitSelectorFields) { + throwBadRequest(`flowSurfaces ${input.context} cannot mix fields and selectorFields on the same field`); + } + if (explicitFields && !FIELD_TYPES_WITH_FIELDS.has(fieldType)) { + throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support fields`); + } + if (explicitSelectorFields && !FIELD_TYPES_WITH_SELECTOR_FIELDS.has(fieldType)) { + throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support selectorFields`); + } + if ((!_.isUndefined(input.openMode) || !_.isUndefined(input.popupSize)) && fieldType !== 'picker') { + throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support openMode or popupSize`); + } + if ( + (!_.isUndefined(input.pageSize) || !_.isUndefined(input.showIndex)) && + !FIELD_TYPES_WITH_TABLE_PROPS.has(fieldType) + ) { + throwBadRequest(`flowSurfaces ${input.context} fieldType '${fieldType}' does not support pageSize or showIndex`); + } + const shouldApplyDefaults = input.applyDefaults !== false; + const defaultTargetField = shouldApplyDefaults ? pickCollectionFallbackFieldName(targetCollection) : undefined; + const fields = + explicitFields ?? (shouldApplyDefaults && usesNestedRelationFields(fieldUse) ? [defaultTargetField] : undefined); + const selectorFields = + explicitSelectorFields ?? + (shouldApplyDefaults && fieldType === 'picker' && _.isUndefined(input.selectorFields) + ? [defaultTargetField] + : undefined); + const titleField = _.isUndefined(input.titleField) + ? defaultTargetField + : String(input.titleField || '').trim() || undefined; + + if (fields?.length) { + assertTargetFieldNamesExist(targetCollection, fields, `${input.context}.fields`); + } + if (selectorFields?.length) { + assertTargetFieldNamesExist(targetCollection, selectorFields, `${input.context}.selectorFields`); + } + if (titleField && !resolveFieldFromCollection(targetCollection, titleField)) { + throwBadRequest( + `flowSurfaces ${input.context}.titleField '${titleField}' does not exist on relation target collection`, + ); + } + + return { + fieldType, + fieldUse, + cardinality, + targetCollection, + fields, + selectorFields, + titleField, + openMode: input.openMode, + popupSize: input.popupSize, + pageSize: input.pageSize, + showIndex: input.showIndex, + }; +} + +export function usesNestedRelationFields(fieldUse?: string) { + return [ + 'SubTableFieldModel', + 'PopupSubTableFieldModel', + 'DisplaySubTableFieldModel', + 'SubFormFieldModel', + 'SubFormListFieldModel', + 'DisplaySubItemFieldModel', + 'DisplaySubListFieldModel', + ].includes(String(fieldUse || '').trim()); +} diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/node-use-sets.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/node-use-sets.ts index 2bb217b7c77..b25bf7124e1 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/node-use-sets.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/node-use-sets.ts @@ -12,6 +12,7 @@ import { APPROVAL_DETAILS_BLOCK_USES, APPROVAL_FIELD_WRAPPER_USES, APPROVAL_FORM export const COLLECTION_BLOCK_USES = new Set([ 'TableBlockModel', 'CalendarBlockModel', + 'TreeBlockModel', 'KanbanBlockModel', 'CreateFormModel', 'EditFormModel', diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service-utils.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service-utils.ts index 868342c5567..f4d1012a35f 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service-utils.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service-utils.ts @@ -25,6 +25,12 @@ import { import { getFieldInterface } from './service-helpers'; import type { FlowSurfaceNodeSpec, FlowSurfaceNodeSubModel } from './types'; import { FLOW_SURFACE_RESERVED_KEYS } from './planning/key-registry'; +import { + assertNoInternalFieldKeys, + normalizePublicFieldNameList, + normalizePublicFieldType, + type FlowSurfacePublicRelationFieldType, +} from './field-type-resolver'; export function buildDefinedPayload(payload: Record) { return _.pickBy(payload, (value) => !_.isUndefined(value)); @@ -301,7 +307,18 @@ export function hasDefinedValue(input: Record, keys: string[]) { } export function ensureNoRawSimpleChangeKeys(changes: Record) { - const rawKeys = ['props', 'decoratorProps', 'stepParams', 'flowRegistry', 'use', 'fieldUse']; + const rawKeys = [ + 'props', + 'decoratorProps', + 'stepParams', + 'flowRegistry', + 'use', + 'fieldUse', + 'fieldComponent', + 'fieldModel', + 'componentFields', + 'subModels', + ]; const forbidden = Object.keys(changes).filter((key) => rawKeys.includes(key)); if (forbidden.length) { throwBadRequest( @@ -340,6 +357,14 @@ export type NormalizedComposeFieldSpec = { associationPathName?: string; renderer?: string; type?: string; + fieldType?: FlowSurfacePublicRelationFieldType; + fields?: string[]; + selectorFields?: string[]; + titleField?: string; + openMode?: string; + popupSize?: string; + pageSize?: number; + showIndex?: boolean; target?: string; settings: Record; popup?: Record; @@ -365,12 +390,22 @@ export function normalizeComposeFieldSpec(input: any, index: number): Normalized if (!_.isPlainObject(input)) { throwBadRequest(`flowSurfaces compose field #${index + 1} must be a string or object`); } - if (input.use || input.fieldUse || input.stepParams || input.props || input.decoratorProps) { - throwBadRequest('flowSurfaces compose field only accepts public semantic field fields'); - } + assertNoInternalFieldKeys(input, `flowSurfaces compose field #${index + 1}`); + assertNoInternalFieldKeys(input.settings, `flowSurfaces compose field #${index + 1}.settings`); const semanticType = String(input.type || '').trim() || undefined; const fieldPath = String(input.fieldPath || '').trim(); const renderer = typeof input.renderer === 'undefined' ? undefined : String(input.renderer || '').trim(); + const fieldType = normalizePublicFieldType(input.fieldType, `compose field #${index + 1}`); + const fields = normalizePublicFieldNameList(input.fields, `compose field #${index + 1}.fields`); + const selectorFields = normalizePublicFieldNameList( + input.selectorFields, + `compose field #${index + 1}.selectorFields`, + ); + const titleField = typeof input.titleField === 'undefined' ? undefined : String(input.titleField || '').trim(); + const openMode = typeof input.openMode === 'undefined' ? undefined : String(input.openMode || '').trim(); + const popupSize = typeof input.popupSize === 'undefined' ? undefined : String(input.popupSize || '').trim(); + const pageSize = typeof input.pageSize === 'undefined' ? undefined : Number(input.pageSize); + const showIndex = typeof input.showIndex === 'undefined' ? undefined : !!input.showIndex; if (!_.isUndefined(input.popup) && !_.isPlainObject(input.popup)) { throwBadRequest(`flowSurfaces compose field #${index + 1} popup must be an object`); } @@ -397,6 +432,14 @@ export function normalizeComposeFieldSpec(input: any, index: number): Normalized associationPathName: String(input.associationPathName || '').trim() || undefined, ...(renderer ? { renderer } : {}), ...(semanticType ? { type: semanticType } : {}), + ...(fieldType ? { fieldType } : {}), + ...(!_.isUndefined(fields) ? { fields } : {}), + ...(!_.isUndefined(selectorFields) ? { selectorFields } : {}), + ...(titleField ? { titleField } : {}), + ...(openMode ? { openMode } : {}), + ...(popupSize ? { popupSize } : {}), + ...(!_.isUndefined(pageSize) && Number.isFinite(pageSize) ? { pageSize } : {}), + ...(!_.isUndefined(showIndex) ? { showIndex } : {}), target: typeof input.target === 'string' ? String(input.target || '').trim() || undefined : undefined, settings: _.isPlainObject(input.settings) ? input.settings : {}, popup: _.isPlainObject(input.popup) ? input.popup : undefined, @@ -405,6 +448,19 @@ export function normalizeComposeFieldSpec(input: any, index: number): Normalized }; } +export function resolveRequestedFieldComponent(values: Record | undefined) { + return undefined; +} + +export function resolveRequestedFieldUse(values: Record | undefined) { + return resolveRequestedFieldComponent(values) || resolveRequestedFieldUseAlias(values); +} + +export function resolveRequestedFieldUseAlias(values: Record | undefined) { + const legacyFieldUse = String(values?.fieldUse || '').trim(); + return legacyFieldUse || undefined; +} + export function normalizeComposeActionSpec(input: any, index: number) { if (typeof input === 'string') { const type = String(input || '').trim(); @@ -628,13 +684,19 @@ export function splitComposeFieldChanges(changes: Record, wrapperUs 'disabled', 'maxCount', 'pattern', - 'titleField', 'dataIndex', 'editable', 'labelWidth', 'labelWrap', 'name', - 'fieldComponent', + 'fieldType', + 'fields', + 'selectorFields', + 'titleField', + 'openMode', + 'popupSize', + 'pageSize', + 'showIndex', ...(wrapperUse === 'TableColumnModel' ? ['title'] : []), ]); const fieldChanges = _.pick(changes, [ diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts index 80fc3cf8674..57b2cf9441a 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/service.ts @@ -30,6 +30,7 @@ import { buildBlockTree, buildCanonicalTableActionsColumnNode, buildFieldTree, + getStandaloneFieldDefaults, buildStandaloneFieldNode, buildPersistedRootPageModel, buildPopupPageTree, @@ -80,11 +81,8 @@ import { collectFlowSurfaceCreatedKeys, collectPersistableFlowSurfaceCreatedKeys import { persistDeclaredKeyForNode as persistPlanningDeclaredKeyForNode } from './planning/key-persistence'; import { validateFlowSurfacePayloadShape } from './payload-shape'; import type { FlowSurfacePlanSurfaceContext } from './planning/types'; -import { - MULTI_VALUE_ASSOCIATION_INTERFACES, - normalizeFieldContainerKind, - shouldUseAssociationTitleTextDisplay, -} from './field-semantics'; +import { normalizeFieldContainerKind, shouldUseAssociationTitleTextDisplay } from './field-semantics'; +import { MULTI_VALUE_ASSOCIATION_INTERFACES } from './association-interfaces'; import { resolveRegisteredFieldBinding } from './field-binding-registry'; import { ACTION_BUTTON_USES, @@ -117,7 +115,11 @@ import { projectCatalogNode as projectSmartCatalogNode, type FlowSurfaceCatalogProjectableItem, } from './catalog-smart'; -import { compileComposeExecutionPlan, type FlowSurfaceComposeNormalizedBlockSpec } from './compose-compiler'; +import { + compileComposeExecutionPlan, + type FlowSurfaceComposeNormalizedBlockSpec, + type FlowSurfaceComposeTargetKey, +} from './compose-compiler'; import { executeComposeRuntime } from './compose-runtime'; import { SurfaceLocator } from './locator'; import { isPageModelUse, isPopupHostUse } from './placement'; @@ -264,6 +266,9 @@ import { normalizeComposeFieldSpec, normalizeGridCardColumns, normalizePublicBlockHeightMode, + resolveRequestedFieldComponent, + resolveRequestedFieldUse, + resolveRequestedFieldUseAlias, normalizeRowSpans, normalizeSimpleConfirm, normalizeSimpleLayoutValue, @@ -273,6 +278,12 @@ import { splitComposeFieldChanges, toFlowSurfaceBatchItemError, } from './service-utils'; +import { + assertNoInternalFieldKeys, + getPublicFieldTypeForUse, + resolveRelationFieldType, + usesNestedRelationFields, +} from './field-type-resolver'; import { areFlowTemplateRootUsesCompatible, buildTemplateMissingContextReason, @@ -403,6 +414,7 @@ type KanbanPopupActionKey = (typeof KANBAN_POPUP_ACTION_KEYS)[number]; const CANONICAL_BLOCK_HEADER_USES = new Set([ 'TableBlockModel', 'CalendarBlockModel', + 'TreeBlockModel', 'KanbanBlockModel', 'FormBlockModel', 'CreateFormModel', @@ -429,6 +441,7 @@ const OPEN_VIEW_SUPPORTED_MODES = new Set(['drawer', 'dialog', 'embed']); const FILTER_TARGET_BLOCK_USES = new Set([ 'TableBlockModel', 'CalendarBlockModel', + 'TreeBlockModel', 'KanbanBlockModel', 'DetailsBlockModel', 'ListBlockModel', @@ -568,6 +581,7 @@ const POPUP_COLLECTION_BLOCK_SCENES: Partial, enabledPackages?: ReadonlySet, ) { - if (!projected?.configureOptions?.fieldComponent || !node?.use) { + if (!projected?.configureOptions?.fieldType || !node?.use) { return projected; } try { @@ -1463,21 +1477,104 @@ export class FlowSurfacesService { if (!supportedFieldUses?.size) { return projected; } + const supportedFieldTypes = Array.from(supportedFieldUses) + .map((use) => getPublicFieldTypeForUse(use)) + .filter(Boolean); const configureOptions = _.cloneDeep(projected.configureOptions || {}); - configureOptions.fieldComponent = { - type: configureOptions.fieldComponent?.type || 'string', - ...(configureOptions.fieldComponent || {}), - enum: Array.from(supportedFieldUses), + configureOptions.fieldType = { + type: configureOptions.fieldType?.type || 'string', + ...(configureOptions.fieldType || {}), + enum: Array.from(new Set(supportedFieldTypes)), }; + const innerField = node?.subModels?.field || node; + const relationFieldTypes = Array.from(new Set(supportedFieldTypes)); + const defaultTitleField = this.getAssociationDefaultTitleFieldName( + fieldSource.field, + fieldSource.fieldSettingsInit?.dataSourceKey, + ); return { ...projected, configureOptions, + relation: { + ...(projected as any).relation, + fieldTypes: relationFieldTypes, + current: buildDefinedPayload({ + fieldType: getPublicFieldTypeForUse(innerField?.stepParams?.fieldBinding?.use || innerField?.use), + fields: this.collectRelationNestedFieldPaths(innerField), + selectorFields: this.collectRelationSelectorFieldPaths(innerField), + titleField: innerField?.props?.titleField || node?.props?.titleField, + }), + defaults: buildDefinedPayload({ + titleField: defaultTitleField, + }), + candidates: this.buildRelationFieldTypeCandidates(relationFieldTypes, defaultTitleField), + configureOptions, + }, }; } catch { return projected; } } + private buildRelationFieldTypeCandidates(fieldTypes: string[], defaultTitleField?: string) { + return fieldTypes.map((fieldType) => { + const defaults: Record = { + titleField: defaultTitleField, + }; + if ( + ['subForm', 'subFormList', 'subDetails', 'subDetailsList', 'subTable', 'popupSubTable'].includes(fieldType) && + defaultTitleField + ) { + defaults.fields = [defaultTitleField]; + } + if (fieldType === 'picker' && defaultTitleField) { + defaults.selectorFields = [defaultTitleField]; + } + return { + fieldType, + defaults: buildDefinedPayload(defaults), + }; + }); + } + + private collectRelationNestedFieldPaths(fieldNode: any) { + const fieldUse = String(fieldNode?.stepParams?.fieldBinding?.use || fieldNode?.use || '').trim(); + if (['SubTableFieldModel', 'DisplaySubTableFieldModel'].includes(fieldUse)) { + return _.castArray(fieldNode?.subModels?.columns || []) + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath) + .filter(Boolean); + } + if (fieldUse === 'PopupSubTableFieldModel') { + return _.castArray(fieldNode?.subModels?.subTableColumns || []) + .filter((item: any) => item?.use === 'TableColumnModel') + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath) + .filter(Boolean); + } + if ( + ['SubFormFieldModel', 'SubFormListFieldModel', 'DisplaySubItemFieldModel', 'DisplaySubListFieldModel'].includes( + fieldUse, + ) + ) { + return _.castArray(fieldNode?.subModels?.grid?.subModels?.items || []) + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath) + .filter(Boolean); + } + return undefined; + } + + private collectRelationSelectorFieldPaths(fieldNode: any) { + const fieldUse = String(fieldNode?.stepParams?.fieldBinding?.use || fieldNode?.use || '').trim(); + if (fieldUse !== 'RecordPickerFieldModel') { + return undefined; + } + const table = _.castArray(fieldNode?.subModels?.['grid-block']?.subModels?.items || []).find( + (item: any) => item?.use === 'TableBlockModel', + ); + return _.castArray(table?.subModels?.columns || []) + .map((item: any) => item?.stepParams?.fieldSettings?.init?.fieldPath) + .filter(Boolean); + } + private projectCatalogItem( item: FlowSurfaceCatalogProjectableItem, expand: ReturnType, @@ -5116,6 +5213,9 @@ export class FlowSurfacesService { if (current?.use === 'CalendarBlockModel') { return this.configureCalendarBlock(target, current, values.changes, options); } + if (current?.use === 'TreeBlockModel') { + return this.configureTreeBlock(target, current, values.changes, options); + } if (current?.use === 'KanbanBlockModel') { return this.configureKanbanBlock(target, current, values.changes, options); } @@ -5174,7 +5274,10 @@ export class FlowSurfacesService { return this.configureFieldNode(target, values.changes, options); } if (ACTION_BUTTON_USES.has(current?.use || '')) { - return this.configureActionNode(target, current.use, values.changes, options); + return this.configureActionNode(target, current.use, values.changes, { + ...options, + current, + }); } throwBadRequest(`flowSurfaces configure does not support configureOptions on '${current?.use || resolved.uid}'`); @@ -6253,6 +6356,9 @@ export class FlowSurfacesService { throwBadRequest('flowSurfaces addBlock does not allow resource and resourceInit at the same time'); } const enabledPackages = await this.resolveEnabledPluginPackages(options); + const hasInlineFields = + Object.prototype.hasOwnProperty.call(values || {}, 'fields') || + Object.prototype.hasOwnProperty.call(values || {}, 'fieldsLayout'); let resolvedTarget = await this.locator.resolve(target, options); let targetNode = await this.loadResolvedNode(resolvedTarget, options.transaction); const targetOpenView = this.resolvePopupHostOpenView(targetNode); @@ -6287,6 +6393,20 @@ export class FlowSurfacesService { requireCreateSupported: true, }, ); + const inlineFields = hasInlineFields + ? this.normalizeComposeBlock( + { + key: + String(values?.key || catalogItem.key || values?.type || values?.use || 'addBlock_inline').trim() || + 'addBlock_inline', + type: catalogItem.key || values.type, + fields: values.fields, + fieldsLayout: values.fieldsLayout, + }, + 0, + enabledPackages, + ) + : null; const resolvedResourceInit = await this.resolvePopupCollectionBlockResourceInit({ actionName: 'addBlock', blockUse: catalogItem.use, @@ -6377,6 +6497,76 @@ export class FlowSurfacesService { : {}), }; await this.applyInlineNodeSettings('addBlock', created, inlineSettings, options); + if (inlineFields?.fields?.length) { + const fieldTargetUid = this.resolveComposeFieldContainerUid(inlineFields, result); + const createdByKey: Record = {}; + for (const fieldSpec of inlineFields.fields) { + const createdField = await this.addField( + { + target: { + uid: fieldTargetUid, + }, + ...(fieldSpec.key ? { key: fieldSpec.key } : {}), + ...(fieldSpec.fieldPath ? { fieldPath: fieldSpec.fieldPath } : {}), + ...(fieldSpec.associationPathName ? { associationPathName: fieldSpec.associationPathName } : {}), + ...(fieldSpec.renderer ? { renderer: fieldSpec.renderer } : {}), + ...(fieldSpec.type ? { type: fieldSpec.type } : {}), + ...(fieldSpec.fieldType ? { fieldType: fieldSpec.fieldType } : {}), + ...(!_.isUndefined(fieldSpec.fields) ? { fields: fieldSpec.fields } : {}), + ...(!_.isUndefined(fieldSpec.selectorFields) ? { selectorFields: fieldSpec.selectorFields } : {}), + ...(fieldSpec.titleField ? { titleField: fieldSpec.titleField } : {}), + ...(fieldSpec.openMode ? { openMode: fieldSpec.openMode } : {}), + ...(fieldSpec.popupSize ? { popupSize: fieldSpec.popupSize } : {}), + ...(!_.isUndefined(fieldSpec.pageSize) ? { pageSize: fieldSpec.pageSize } : {}), + ...(!_.isUndefined(fieldSpec.showIndex) ? { showIndex: fieldSpec.showIndex } : {}), + ...(fieldSpec.popup ? { popup: fieldSpec.popup } : {}), + ...(fieldSpec.__autoPopupForRelationField ? { __autoPopupForRelationField: true } : {}), + ...(fieldSpec[FLOW_SURFACE_APPLY_BLUEPRINT_POPUP_DEFAULTS_KEY] + ? { + [FLOW_SURFACE_APPLY_BLUEPRINT_POPUP_DEFAULTS_KEY]: + fieldSpec[FLOW_SURFACE_APPLY_BLUEPRINT_POPUP_DEFAULTS_KEY], + } + : {}), + }, + { + transaction: options.transaction, + enabledPackages, + }, + ); + if (fieldSpec.settings && Object.keys(fieldSpec.settings).length) { + await this.applyInlineFieldSettings('addBlock field', createdField, fieldSpec.settings, { + transaction: options.transaction, + }); + } + const layoutUid = createdField.wrapperUid || createdField.uid; + if (layoutUid && fieldSpec.key) { + createdByKey[fieldSpec.key] = { uid: layoutUid }; + } + } + if (inlineFields.fieldsLayout && Object.keys(createdByKey).length) { + const layoutHost = await this.repository.findModelById(created, { + transaction: options.transaction, + includeAsyncNode: true, + }); + const layoutItems = _.castArray( + layoutHost?.subModels?.grid?.subModels?.items || layoutHost?.subModels?.items || [], + ); + const layoutPayload = this.buildComposeLayoutPayload({ + layout: inlineFields.fieldsLayout, + createdByKey, + finalItems: layoutItems, + }); + await this.setLayout( + { + target: { + uid: created, + }, + ...layoutPayload, + }, + options, + ); + } + } if (!options.skipDefaultBlockActions) { await this.applyDefaultActionsForCreatedBlock( { @@ -6433,6 +6623,8 @@ export class FlowSurfacesService { return result; } const target = await this.prepareWriteTarget('addField', values?.target, values, options); + assertNoInternalFieldKeys(values, 'flowSurfaces addField'); + assertNoInternalFieldKeys(values?.settings, 'flowSurfaces addField.settings'); ensureNoRawDirectAddKeys('addField', values, [ 'wrapperProps', 'fieldProps', @@ -6449,6 +6641,8 @@ export class FlowSurfacesService { const enabledPackages = await this.resolveEnabledPluginPackages(options); const isFilterFormItem = container.wrapperUse === 'FilterFormItemModel'; const isApprovalFormTarget = isApprovalFormContainerUse(container.ownerUse); + const requestedLegacyFieldUse = resolveRequestedFieldUseAlias(values); + let requestedFieldUse = resolveRequestedFieldUse(values); const requestedStandaloneType = typeof values.type === 'string' && values.type.trim().length ? values.type.trim() : undefined; const fieldCapability = resolveSupportedFieldCapability({ @@ -6461,6 +6655,9 @@ export class FlowSurfacesService { }); if (fieldCapability.standaloneUse) { + if (hasOwnDefined(values, 'fieldType')) { + throwBadRequest('flowSurfaces fieldType is only supported for relation fields'); + } if (inlinePopup) { throwBadRequest(`flowSurfaces addField type '${values.type}' does not support popup`); } @@ -6583,6 +6780,32 @@ export class FlowSurfacesService { associationPathName: resolvedField.associationPathName, field: resolvedField.field, }); + const relationFieldTypeResolution = resolveRelationFieldType({ + fieldType: values.fieldType, + containerUse: container.ownerUse, + field: resolvedField.field, + dataSourceKey: resolvedField.dataSourceKey, + getCollection: (dataSourceKey, collectionName) => this.getCollection(dataSourceKey, collectionName), + fields: values.fields, + selectorFields: values.selectorFields, + titleField: values.titleField, + openMode: values.openMode, + popupSize: values.popupSize, + pageSize: values.pageSize, + showIndex: values.showIndex, + context: 'addField', + }); + if (relationFieldTypeResolution) { + if (values.renderer) { + throwBadRequest(`flowSurfaces addField fieldType cannot be combined with renderer`); + } + if (requestedLegacyFieldUse && requestedLegacyFieldUse !== relationFieldTypeResolution.fieldUse) { + throwBadRequest( + `flowSurfaces fieldUse '${requestedLegacyFieldUse}' does not match fieldType '${relationFieldTypeResolution.fieldType}'`, + ); + } + requestedFieldUse = relationFieldTypeResolution.fieldUse; + } const filterFormInit = isFilterFormItem ? { @@ -6593,25 +6816,29 @@ export class FlowSurfacesService { let capabilityField = preferredCapabilityField; let boundFieldCapability; if (fieldMenuCandidate?.explicitWrapperUse && fieldMenuCandidate?.explicitFieldUse && !values.renderer) { - if (hasOwnDefined(values, 'fieldUse') && values.fieldUse !== fieldMenuCandidate.explicitFieldUse) { + if (requestedLegacyFieldUse && requestedLegacyFieldUse !== fieldMenuCandidate.explicitFieldUse) { throwBadRequest( - `flowSurfaces fieldUse '${values.fieldUse}' does not match inferred fieldUse '${fieldMenuCandidate.explicitFieldUse}' under '${container.ownerUse}'`, + `flowSurfaces fieldUse '${requestedLegacyFieldUse}' does not match inferred fieldUse '${fieldMenuCandidate.explicitFieldUse}' under '${container.ownerUse}'`, ); } - boundFieldCapability = { - wrapperUse: fieldMenuCandidate.explicitWrapperUse, - fieldUse: fieldMenuCandidate.explicitFieldUse, - inferredFieldUse: fieldMenuCandidate.explicitFieldUse, - standaloneUse: undefined, - renderer: undefined, - }; - capabilityField = resolvedField.field; - } else { + if (!relationFieldTypeResolution) { + boundFieldCapability = { + wrapperUse: fieldMenuCandidate.explicitWrapperUse, + fieldUse: fieldMenuCandidate.explicitFieldUse, + inferredFieldUse: fieldMenuCandidate.explicitFieldUse, + standaloneUse: undefined, + renderer: undefined, + }; + capabilityField = resolvedField.field; + } + } + if (!boundFieldCapability) { try { boundFieldCapability = resolveSupportedFieldCapability({ containerUse: container.ownerUse, field: capabilityField, - requestedFieldUse: values.fieldUse, + requestedFieldUse, + requestedFieldUseMode: relationFieldTypeResolution ? 'fieldType' : 'fieldUse', requestedWrapperUse: container.wrapperUse, requestedRenderer: values.renderer, enabledPackages, @@ -6620,7 +6847,7 @@ export class FlowSurfacesService { }); } catch (error) { if ( - hasOwnDefined(values, 'fieldUse') && + requestedFieldUse && capabilityField !== resolvedField.field && error instanceof FlowSurfaceBadRequestError ) { @@ -6628,7 +6855,8 @@ export class FlowSurfacesService { boundFieldCapability = resolveSupportedFieldCapability({ containerUse: container.ownerUse, field: capabilityField, - requestedFieldUse: values.fieldUse, + requestedFieldUse, + requestedFieldUseMode: relationFieldTypeResolution ? 'fieldType' : 'fieldUse', requestedWrapperUse: container.wrapperUse, requestedRenderer: values.renderer, enabledPackages, @@ -6675,13 +6903,23 @@ export class FlowSurfacesService { capabilityField, ); const defaultTitleField = + relationFieldTypeResolution?.titleField ?? titleFieldSyncDecision.titleField ?? normalizedFieldBinding.defaultTitleField ?? fieldMenuCandidate?.defaultTitleField; const wrapperShouldPersistTitleField = !_.isUndefined(defaultTitleField) && TITLE_FIELD_SUPPORTED_WRAPPER_USES.has(boundFieldCapability.wrapperUse || ''); - const fieldShouldPersistTitleField = !_.isUndefined(defaultTitleField); + const fieldShouldPersistTitleField = + !_.isUndefined(defaultTitleField) && this.supportsFieldTitleFieldProp(boundFieldCapability.fieldUse); + const normalizedDefaultFieldProps = this.normalizeFieldPropsForUse( + boundFieldCapability.fieldUse, + defaultFieldState.fieldProps || {}, + ); + const normalizedInlineFieldProps = this.normalizeFieldPropsForUse( + boundFieldCapability.fieldUse, + values.fieldProps || {}, + ); const tree = buildFieldTree({ wrapperUse: boundFieldCapability.wrapperUse, @@ -6699,9 +6937,9 @@ export class FlowSurfacesService { ), fieldProps: _.merge( {}, - defaultFieldState.fieldProps || {}, + normalizedDefaultFieldProps, fieldShouldPersistTitleField ? { titleField: defaultTitleField } : {}, - values.fieldProps || {}, + normalizedInlineFieldProps, ), }); this.contractGuard.validateNodeTreeAgainstContract(tree.model); @@ -6745,6 +6983,21 @@ export class FlowSurfacesService { associationPathName: normalizedFieldBinding.associationPathName, fieldPath: normalizedFieldBinding.fieldPath, }; + if (relationFieldTypeResolution) { + await this.applyResolvedRelationFieldType({ + fieldUid: tree.innerUid, + fieldUse: boundFieldCapability.fieldUse, + targetCollection: relationFieldTypeResolution.targetCollection, + fields: relationFieldTypeResolution.fields, + selectorFields: relationFieldTypeResolution.selectorFields, + titleField: relationFieldTypeResolution.titleField, + openMode: relationFieldTypeResolution.openMode, + popupSize: relationFieldTypeResolution.popupSize, + pageSize: relationFieldTypeResolution.pageSize, + showIndex: relationFieldTypeResolution.showIndex, + transaction: options.transaction, + }); + } await this.applyInlineFieldSettings('addField', result, inlineSettings, options); await this.applyInlineFieldPopup('addField', result, inlinePopup, { ...options, @@ -11810,6 +12063,7 @@ export class FlowSurfacesService { ); const hasFields = Object.prototype.hasOwnProperty.call(input, 'fields'); const hasFieldGroups = Object.prototype.hasOwnProperty.call(input, 'fieldGroups'); + const hasActions = Object.prototype.hasOwnProperty.call(input, 'actions'); const hasRecordActions = Object.prototype.hasOwnProperty.call(input, 'recordActions'); if (type === 'calendar') { if (hasFields) { @@ -11855,6 +12109,23 @@ export class FlowSurfacesService { ); } } + if (type === 'tree') { + if (hasFields) { + throwBadRequest(`flowSurfaces compose block #${index + 1} tree does not support fields[]`); + } + if (hasFieldGroups) { + throwBadRequest(`flowSurfaces compose block #${index + 1} tree does not support fieldGroups[]`); + } + if (hasActions) { + throwBadRequest(`flowSurfaces compose block #${index + 1} tree does not support actions[]`); + } + if (hasRecordActions) { + throwBadRequest(`flowSurfaces compose block #${index + 1} tree does not support recordActions[]`); + } + if (Object.prototype.hasOwnProperty.call(input, 'fieldsLayout')) { + throwBadRequest(`flowSurfaces compose block #${index + 1} tree does not support fieldsLayout`); + } + } if (hasFields && hasFieldGroups) { throwBadRequest(`flowSurfaces compose block #${index + 1} cannot mix fields with fieldGroups`); } @@ -12513,6 +12784,84 @@ export class FlowSurfacesService { return result; } + private async configureTreeBlock( + target: FlowSurfaceWriteTarget, + current: any, + changes: Record, + options: { transaction?: any }, + ) { + const allowedKeys = getConfigureOptionKeysForUse('TreeBlockModel'); + const cardSettings = buildBlockTitleDescriptionFromSemanticChanges(changes); + assertSupportedSimpleChanges('tree', changes, allowedKeys); + + const nextFieldNames = _.isPlainObject(changes.fieldNames) + ? _.merge({}, current?.props?.fieldNames || {}, changes.fieldNames) + : _.cloneDeep(current?.props?.fieldNames || {}); + if (hasOwnDefined(changes, 'titleField')) { + nextFieldNames.title = String(changes.titleField || '').trim(); + } + const hasFieldNamesPatch = hasOwnDefined(changes, 'titleField') || _.isPlainObject(changes.fieldNames); + + return this.updateSettings( + { + target, + props: buildDefinedPayload({ + ...(hasOwnDefined(changes, 'searchable') ? { searchable: changes.searchable !== false } : {}), + ...(hasOwnDefined(changes, 'defaultExpandAll') + ? { defaultExpandAll: changes.defaultExpandAll === true } + : {}), + ...(hasOwnDefined(changes, 'includeDescendants') + ? { includeDescendants: changes.includeDescendants !== false } + : {}), + ...(hasFieldNamesPatch ? { fieldNames: nextFieldNames } : {}), + ...(hasOwnDefined(changes, 'pageSize') ? { pageSize: changes.pageSize } : {}), + }), + decoratorProps: buildDefinedPayload({ + height: changes.height, + heightMode: normalizePublicBlockHeightMode(changes.heightMode), + }), + stepParams: { + ...(cardSettings ? { cardSettings } : {}), + ...(changes.resource + ? { + resourceSettings: { + init: normalizeSimpleResourceInit(changes.resource), + }, + } + : {}), + ...(hasDefinedValue(changes, [ + 'searchable', + 'defaultExpandAll', + 'includeDescendants', + 'titleField', + 'pageSize', + 'dataScope', + 'sorting', + ]) + ? { + treeSettings: buildDefinedPayload({ + ...(hasOwnDefined(changes, 'searchable') + ? { searchable: { searchable: changes.searchable !== false } } + : {}), + ...(hasOwnDefined(changes, 'defaultExpandAll') + ? { defaultExpandAll: { defaultExpandAll: changes.defaultExpandAll === true } } + : {}), + ...(hasOwnDefined(changes, 'includeDescendants') + ? { includeDescendants: { includeDescendants: changes.includeDescendants !== false } } + : {}), + ...(hasOwnDefined(changes, 'titleField') ? { titleField: { titleField: nextFieldNames.title } } : {}), + ...(hasOwnDefined(changes, 'pageSize') ? { pageSize: { pageSize: changes.pageSize } } : {}), + ...(hasOwnDefined(changes, 'dataScope') ? { dataScope: { filter: changes.dataScope } } : {}), + ...(hasOwnDefined(changes, 'sorting') ? { defaultSorting: { sort: changes.sorting } } : {}), + }), + } + : {}), + }, + }, + options, + ); + } + private async configureKanbanBlock( target: FlowSurfaceWriteTarget, current: any, @@ -13679,7 +14028,19 @@ export class FlowSurfacesService { const enabledPackages = await this.resolveEnabledPluginPackages(options); assertSupportedSimpleChanges('field wrapper', changes, getConfigureOptionKeysForUse(current?.use)); - const rawWrapperChanges = _.omit(changes, ['clickToOpen', 'openView', 'code', 'version', 'fieldComponent']); + const rawWrapperChanges = _.omit(changes, [ + 'clickToOpen', + 'openView', + 'code', + 'version', + 'fieldType', + 'fields', + 'selectorFields', + 'openMode', + 'popupSize', + 'pageSize', + 'showIndex', + ]); const wrapperChanges = current?.use === 'TableColumnModel' && !hasOwnDefined(rawWrapperChanges, 'title') && @@ -13729,6 +14090,9 @@ export class FlowSurfacesService { }); const shouldSyncTitleField = titleFieldSyncDecision.shouldSync; const syncedTitleField = titleFieldSyncDecision.titleField; + const shouldPatchTableColumnTitle = current?.use === 'TableColumnModel' && hasOwnDefined(wrapperChanges, 'title'); + const shouldPatchFieldSettings = + hasDefinedValue(wrapperChanges, ['fieldPath', 'associationPathName']) || shouldPatchTableColumnTitle; if (Object.keys(wrapperChanges).length) { await this.updateSettings( @@ -13769,21 +14133,19 @@ export class FlowSurfacesService { labelWidth: wrapperChanges.labelWidth, labelWrap: wrapperChanges.labelWrap, }), - stepParams: - hasDefinedValue(wrapperChanges, ['fieldPath', 'associationPathName']) || - (current?.use === 'TableColumnModel' && hasOwnDefined(wrapperChanges, 'title')) - ? buildDefinedPayload({ - ...(current?.use === 'TableColumnModel' && hasOwnDefined(wrapperChanges, 'title') - ? { - tableColumnSettings: { - title: { - title: wrapperChanges.title, - }, + stepParams: shouldPatchFieldSettings + ? buildDefinedPayload({ + ...(shouldPatchTableColumnTitle + ? { + tableColumnSettings: { + title: { + title: wrapperChanges.title, }, - } - : {}), - }) - : undefined, + }, + } + : {}), + }) + : undefined, }, options, ); @@ -13814,25 +14176,87 @@ export class FlowSurfacesService { } } - if (hasOwnDefined(changes, 'fieldComponent')) { + let effectiveInnerFieldUse = innerField?.use; + let fieldTypeResolution; + let relationTitleFieldToApply; + if ( + hasOwnDefined(changes, 'fieldType') || + hasOwnDefined(changes, 'fields') || + hasOwnDefined(changes, 'selectorFields') || + hasOwnDefined(changes, 'titleField') || + hasOwnDefined(changes, 'openMode') || + hasOwnDefined(changes, 'popupSize') || + hasOwnDefined(changes, 'pageSize') || + hasOwnDefined(changes, 'showIndex') + ) { if (!innerUid) { throwConflict( `flowSurfaces configure field wrapper '${current?.use}' cannot resolve inner field`, 'FLOW_SURFACE_INNER_FIELD_MISSING', ); } + const fieldSource = this.resolveFieldComponentFieldSource(current, normalizedBinding); + const currentPublicFieldType = getPublicFieldTypeForUse( + innerField?.stepParams?.fieldBinding?.use || innerField?.use, + ); + const shouldApplyResolverDefaults = + hasOwnDefined(changes, 'fieldType') && changes.fieldType !== currentPublicFieldType; + fieldTypeResolution = resolveRelationFieldType({ + fieldType: hasOwnDefined(changes, 'fieldType') ? changes.fieldType : currentPublicFieldType, + containerUse: current?.use, + field: fieldSource.field, + dataSourceKey: fieldSource.fieldSettingsInit?.dataSourceKey, + getCollection: (dataSourceKey, collectionName) => this.getCollection(dataSourceKey, collectionName), + fields: changes.fields, + selectorFields: changes.selectorFields, + titleField: hasOwnDefined(wrapperChanges, 'titleField') ? wrapperChanges.titleField : undefined, + openMode: hasOwnDefined(changes, 'openMode') ? changes.openMode : undefined, + popupSize: hasOwnDefined(changes, 'popupSize') ? changes.popupSize : undefined, + pageSize: hasOwnDefined(changes, 'pageSize') ? changes.pageSize : undefined, + showIndex: hasOwnDefined(changes, 'showIndex') ? changes.showIndex : undefined, + applyDefaults: shouldApplyResolverDefaults, + context: 'configure', + }); + if (!fieldTypeResolution) { + throwBadRequest('flowSurfaces configure fieldType is required when configuring relation fields'); + } + const shouldApplyFieldTypeDefaults = shouldApplyResolverDefaults; + const shouldApplyRelationTitleField = hasOwnDefined(wrapperChanges, 'titleField') || shouldApplyFieldTypeDefaults; + relationTitleFieldToApply = shouldApplyRelationTitleField ? fieldTypeResolution.titleField : undefined; const normalizedFieldComponentUse = await this.rebuildFieldSubModelOnServer({ wrapperNode: current, innerField, - targetFieldUse: changes.fieldComponent, + targetFieldUse: fieldTypeResolution.fieldUse, normalizedBinding, enabledPackages, transaction: options.transaction, }); - await this.syncFieldComponentStepParams(current, normalizedFieldComponentUse, options.transaction); + effectiveInnerFieldUse = normalizedFieldComponentUse; + await this.syncFieldTypeStepParams(current, normalizedFieldComponentUse, options.transaction); + await this.applyResolvedRelationFieldType({ + fieldUid: innerUid, + fieldUse: normalizedFieldComponentUse, + targetCollection: fieldTypeResolution.targetCollection, + fields: + hasOwnDefined(changes, 'fields') || shouldApplyFieldTypeDefaults ? fieldTypeResolution.fields : undefined, + selectorFields: + hasOwnDefined(changes, 'selectorFields') || shouldApplyFieldTypeDefaults + ? fieldTypeResolution.selectorFields + : undefined, + titleField: relationTitleFieldToApply, + openMode: fieldTypeResolution.openMode, + popupSize: fieldTypeResolution.popupSize, + pageSize: fieldTypeResolution.pageSize, + showIndex: fieldTypeResolution.showIndex, + transaction: options.transaction, + }); } - if (shouldSyncTitleField) { + const effectiveSyncedTitleField = relationTitleFieldToApply ?? syncedTitleField; + if ( + (shouldSyncTitleField || !_.isUndefined(relationTitleFieldToApply)) && + this.supportsFieldTitleFieldProp(effectiveInnerFieldUse) + ) { if (!innerUid) { throwConflict( `flowSurfaces configure field wrapper '${current?.use}' cannot resolve inner field`, @@ -13845,7 +14269,7 @@ export class FlowSurfacesService { uid: innerUid, }, props: { - titleField: syncedTitleField, + titleField: effectiveSyncedTitleField, }, }, options, @@ -13929,6 +14353,7 @@ export class FlowSurfacesService { }); const shouldSyncTitleField = titleFieldSyncDecision.shouldSync; const syncedTitleField = titleFieldSyncDecision.titleField; + const canSyncInnerTitleField = this.supportsFieldTitleFieldProp(current?.use); if (parentWrapper?.uid && canSyncWrapperTitleField && shouldSyncTitleField) { await this.updateSettings( @@ -14004,7 +14429,7 @@ export class FlowSurfacesService { quickCreate: changes.quickCreate, displayStyle: changes.displayStyle, options: changes.options, - ...(shouldSyncTitleField ? { titleField: syncedTitleField } : {}), + ...(shouldSyncTitleField && canSyncInnerTitleField ? { titleField: syncedTitleField } : {}), ...(hasOwnDefined(changes, 'clickToOpen') || !_.isUndefined(changes.openView) ? { clickToOpen: effectiveClickToOpen } : {}), @@ -14089,8 +14514,9 @@ export class FlowSurfacesService { target: FlowSurfaceWriteTarget, use: string, changes: Record, - options: { transaction?: any; openViewActionName?: string }, + options: { transaction?: any; openViewActionName?: string; current?: any }, ) { + changes = await this.normalizeActionPanelActionChanges(changes, options); const allowedKeys = getConfigureOptionKeysForUse(use); assertSupportedSimpleChanges('action', changes, allowedKeys); const normalizedDefaultFilter = hasOwnDefined(changes, 'defaultFilter') @@ -14335,6 +14761,27 @@ export class FlowSurfacesService { ); } + private async normalizeActionPanelActionChanges( + changes: Record, + options: { transaction?: any; current?: any }, + ) { + if (!hasOwnDefined(changes, 'type') || !options.current?.uid) { + return changes; + } + const parentUid = + options.current.parentId || (await this.locator.findParentUid(options.current.uid, options.transaction)); + const parentNode = parentUid + ? await this.repository.findModelById(parentUid, { + transaction: options.transaction, + includeAsyncNode: true, + }) + : null; + if (parentNode?.use !== 'ActionPanelBlockModel') { + return changes; + } + return _.omit(changes, ['type']); + } + private async buildFieldCatalog( target: FlowSurfaceWriteTarget, options: { @@ -17279,6 +17726,21 @@ export class FlowSurfacesService { return resolveFieldFromCollection(targetCollection, titleFieldName) || input.field; } + private supportsFieldTitleFieldProp(use?: string) { + return getConfigureOptionKeysForUse(use).includes('titleField'); + } + + private normalizeFieldPropsForUse(use: string | undefined, props: Record | undefined) { + const normalizedProps = _.cloneDeep(props || {}); + if (this.supportsFieldTitleFieldProp(use)) { + return normalizedProps; + } + if (Object.prototype.hasOwnProperty.call(normalizedProps, 'titleField')) { + delete normalizedProps.titleField; + } + return normalizedProps; + } + private async ensureGridChild(parentUid: string, use: string, transaction?: any) { const existing = await this.repository.findModelByParentId(parentUid, { transaction, @@ -17528,6 +17990,402 @@ export class FlowSurfacesService { }; } + private getCollectionFieldOrBadRequest(collection: any, fieldPath: string, context: string) { + const field = resolveFieldFromCollection(collection, fieldPath); + if (!field) { + throwBadRequest(`flowSurfaces ${context} field '${fieldPath}' does not exist on relation target collection`); + } + return field; + } + + private buildRelationTargetFieldInit(collection: any, fieldPath: string) { + return buildDefinedPayload({ + dataSourceKey: collection?.dataSourceKey || 'main', + collectionName: getCollectionName(collection), + fieldPath, + }); + } + + private buildRelationTargetTableColumnNode(input: { collection: any; fieldPath: string; columnUse: string }) { + const field = this.getCollectionFieldOrBadRequest(input.collection, input.fieldPath, 'fieldType.fields'); + const fieldUse = + input.columnUse === 'SubTableColumnModel' + ? inferFieldMenuEditableFieldUse(getFieldInterface(field)) || 'InputFieldModel' + : inferAssociationLeafDisplayFieldUse(getFieldInterface(field)) || 'DisplayTextFieldModel'; + const title = getFieldTitle(field); + const fieldInit = this.buildRelationTargetFieldInit(input.collection, input.fieldPath); + return { + uid: uid(), + use: input.columnUse, + props: buildDefinedPayload({ + title, + dataIndex: getFieldName(field), + }), + stepParams: { + fieldSettings: { + init: fieldInit, + }, + ...(input.columnUse === 'TableColumnModel' + ? { + tableColumnSettings: { + title: { + title, + }, + }, + } + : {}), + }, + subModels: { + field: { + uid: uid(), + use: fieldUse, + props: this.normalizeFieldPropsForUse( + fieldUse, + getFieldBindingDefaultProps('TableColumnModel', fieldUse, field), + ), + stepParams: { + fieldSettings: { + init: fieldInit, + }, + fieldBinding: { + use: fieldUse, + }, + }, + }, + }, + }; + } + + private buildRelationTargetGridItemNode(input: { collection: any; fieldPath: string; wrapperUse: string }) { + const field = this.getCollectionFieldOrBadRequest(input.collection, input.fieldPath, 'fieldType.fields'); + const fieldUse = + input.wrapperUse === 'FormItemModel' + ? inferFieldMenuEditableFieldUse(getFieldInterface(field)) || 'InputFieldModel' + : inferAssociationLeafDisplayFieldUse(getFieldInterface(field)) || 'DisplayTextFieldModel'; + const title = getFieldTitle(field); + const fieldInit = this.buildRelationTargetFieldInit(input.collection, input.fieldPath); + return { + uid: uid(), + use: input.wrapperUse, + props: buildDefinedPayload({ + label: title, + }), + stepParams: { + fieldSettings: { + init: fieldInit, + }, + }, + subModels: { + field: { + uid: uid(), + use: fieldUse, + props: this.normalizeFieldPropsForUse( + fieldUse, + getFieldBindingDefaultProps(input.wrapperUse, fieldUse, field), + ), + stepParams: { + fieldSettings: { + init: fieldInit, + }, + fieldBinding: { + use: fieldUse, + }, + }, + }, + }, + }; + } + + private async replaceFieldSubModelArray(input: { + parentUid: string; + subKey: string; + keepUses?: string[]; + children: any[]; + transaction?: any; + }) { + const parentNode = await this.repository.findModelById(input.parentUid, { + transaction: input.transaction, + includeAsyncNode: true, + }); + const keptChildren: any[] = []; + for (const child of _.castArray(parentNode?.subModels?.[input.subKey] || [])) { + if (!child?.uid) { + continue; + } + if (input.keepUses?.includes(child.use)) { + keptChildren.push(child); + continue; + } + await this.removeNodeTreeWithBindings(child.uid, input.transaction); + } + for (const child of keptChildren.reverse()) { + await this.repository.attach( + child.uid, + { + parentId: input.parentUid, + subKey: input.subKey, + subType: 'array', + position: 'first', + }, + { transaction: input.transaction }, + ); + } + for (const child of input.children) { + await this.repository.upsertModel( + { + parentId: input.parentUid, + subKey: input.subKey, + subType: 'array', + ...child, + }, + { transaction: input.transaction }, + ); + } + } + + private buildPopupSubTableActionsColumnNode(existing?: any) { + const defaultActionColumn = _.cloneDeep( + getStandaloneFieldDefaults('PopupSubTableFieldModel').subModels?.subTableColumns?.[0], + ); + return { + ...(defaultActionColumn || { + use: 'PopupSubTableActionsColumnModel', + }), + ...(existing || {}), + uid: existing?.uid || defaultActionColumn?.uid || uid(), + }; + } + + private async ensureFieldGridSubModel(input: { fieldUid: string; gridUse: string; transaction?: any }) { + const fieldNode = await this.repository.findModelById(input.fieldUid, { + transaction: input.transaction, + includeAsyncNode: true, + }); + const existingGrid = fieldNode?.subModels?.grid; + if (existingGrid?.uid) { + return existingGrid.uid; + } + const gridUid = uid(); + await this.repository.upsertModel( + { + uid: gridUid, + parentId: input.fieldUid, + subKey: 'grid', + subType: 'object', + use: input.gridUse, + }, + { transaction: input.transaction }, + ); + return gridUid; + } + + private async applyResolvedRelationFieldType(input: { + fieldUid: string; + fieldUse: string; + targetCollection: any; + fields?: string[]; + selectorFields?: string[]; + titleField?: string; + openMode?: string; + popupSize?: string; + pageSize?: any; + showIndex?: any; + transaction?: any; + }) { + if (input.titleField || !_.isUndefined(input.pageSize) || !_.isUndefined(input.showIndex)) { + const fieldNode = await this.repository.findModelById(input.fieldUid, { + transaction: input.transaction, + includeAsyncNode: true, + }); + const props = buildDefinedPayload({ + ...(input.titleField && this.supportsFieldTitleFieldProp(input.fieldUse) + ? { titleField: input.titleField } + : {}), + pageSize: input.pageSize, + showIndex: input.showIndex, + }); + if (Object.keys(props).length) { + await this.repository.patch( + { + uid: input.fieldUid, + props: { + ...(fieldNode?.props || {}), + ...props, + }, + }, + { transaction: input.transaction }, + ); + } + } + + const fields = input.fields; + if (usesNestedRelationFields(input.fieldUse) && fields) { + if (['SubTableFieldModel', 'DisplaySubTableFieldModel'].includes(input.fieldUse)) { + await this.replaceFieldSubModelArray({ + parentUid: input.fieldUid, + subKey: 'columns', + children: fields.map((fieldPath) => + this.buildRelationTargetTableColumnNode({ + collection: input.targetCollection, + fieldPath, + columnUse: input.fieldUse === 'SubTableFieldModel' ? 'SubTableColumnModel' : 'TableColumnModel', + }), + ), + transaction: input.transaction, + }); + } else if (input.fieldUse === 'PopupSubTableFieldModel') { + const fieldNode = await this.repository.findModelById(input.fieldUid, { + transaction: input.transaction, + includeAsyncNode: true, + }); + const existingActionsColumn = _.castArray(fieldNode?.subModels?.subTableColumns || []).find( + (item: any) => item?.use === 'PopupSubTableActionsColumnModel', + ); + await this.replaceFieldSubModelArray({ + parentUid: input.fieldUid, + subKey: 'subTableColumns', + children: [ + this.buildPopupSubTableActionsColumnNode(existingActionsColumn), + ...fields.map((fieldPath) => + this.buildRelationTargetTableColumnNode({ + collection: input.targetCollection, + fieldPath, + columnUse: 'TableColumnModel', + }), + ), + ], + transaction: input.transaction, + }); + } else { + const gridUse = ['SubFormFieldModel', 'SubFormListFieldModel'].includes(input.fieldUse) + ? 'FormGridModel' + : 'DetailsGridModel'; + const wrapperUse = ['SubFormFieldModel', 'SubFormListFieldModel'].includes(input.fieldUse) + ? 'FormItemModel' + : 'DetailsItemModel'; + const gridUid = await this.ensureFieldGridSubModel({ + fieldUid: input.fieldUid, + gridUse, + transaction: input.transaction, + }); + await this.replaceFieldSubModelArray({ + parentUid: gridUid, + subKey: 'items', + children: fields.map((fieldPath) => + this.buildRelationTargetGridItemNode({ + collection: input.targetCollection, + fieldPath, + wrapperUse, + }), + ), + transaction: input.transaction, + }); + } + } + + if (input.fieldUse === 'RecordPickerFieldModel') { + await this.applyRecordPickerFieldTypeSettings(input); + } + } + + private async applyRecordPickerFieldTypeSettings(input: { + fieldUid: string; + targetCollection: any; + selectorFields?: string[]; + openMode?: string; + popupSize?: string; + transaction?: any; + }) { + const fieldNode = await this.repository.findModelById(input.fieldUid, { + transaction: input.transaction, + includeAsyncNode: true, + }); + const openView = buildDefinedPayload({ + mode: input.openMode, + size: input.popupSize, + pageModelClass: 'ChildPageModel', + dataSourceKey: input.targetCollection?.dataSourceKey || 'main', + collectionName: getCollectionName(input.targetCollection), + }); + if (Object.keys(openView).length > 2) { + await this.repository.patch( + { + uid: input.fieldUid, + stepParams: _.merge({}, fieldNode?.stepParams || {}, { + popupSettings: { + openView, + }, + }), + }, + { transaction: input.transaction }, + ); + } + if (!input.selectorFields) { + return; + } + let grid = await this.repository.findModelByParentId(input.fieldUid, { + transaction: input.transaction, + subKey: 'grid-block', + includeAsyncNode: true, + }); + if (!grid?.uid) { + const gridUid = uid(); + await this.repository.upsertModel( + { + uid: gridUid, + parentId: input.fieldUid, + subKey: 'grid-block', + subType: 'object', + use: 'BlockGridModel', + }, + { transaction: input.transaction }, + ); + grid = await this.repository.findModelById(gridUid, { + transaction: input.transaction, + includeAsyncNode: true, + }); + } + if (!grid?.uid) { + return; + } + const existingTable = _.castArray(grid?.subModels?.items || []).find( + (item: any) => item?.use === 'TableBlockModel', + ); + const tableUid = existingTable?.uid || uid(); + if (!existingTable?.uid) { + await this.repository.upsertModel( + { + uid: tableUid, + parentId: grid.uid, + subKey: 'items', + subType: 'array', + use: 'TableBlockModel', + stepParams: { + resourceSettings: { + init: { + dataSourceKey: input.targetCollection?.dataSourceKey || 'main', + collectionName: getCollectionName(input.targetCollection), + }, + }, + }, + }, + { transaction: input.transaction }, + ); + } + await this.replaceFieldSubModelArray({ + parentUid: tableUid, + subKey: 'columns', + children: input.selectorFields.map((fieldPath) => + this.buildRelationTargetTableColumnNode({ + collection: input.targetCollection, + fieldPath, + columnUse: 'TableColumnModel', + }), + ), + transaction: input.transaction, + }); + } + private buildExactFieldSettingsInitPayload(input: { dataSourceKey: string; collectionName: string; @@ -17661,7 +18519,7 @@ export class FlowSurfacesService { const targetFieldUse = input.targetFieldUse; const normalizedTargetUse = String(targetFieldUse || '').trim(); if (!normalizedTargetUse) { - throwBadRequest('flowSurfaces configure fieldComponent cannot be empty'); + throwBadRequest('flowSurfaces configure fieldType cannot be empty'); } const normalizedWrapperUse = String(wrapperUse || '').trim(); const containerUse = @@ -17678,7 +18536,7 @@ export class FlowSurfacesService { }); if (!supportedFieldUses?.has(normalizedTargetUse)) { throwBadRequest( - `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldComponent '${normalizedTargetUse}'`, + `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldType '${normalizedTargetUse}'`, ); } return normalizedTargetUse; @@ -17689,7 +18547,7 @@ export class FlowSurfacesService { }); if (!contract?.fieldUse) { throwBadRequest( - `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldComponent '${normalizedTargetUse}'`, + `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldType '${normalizedTargetUse}'`, ); } return contract.fieldUse; @@ -17706,7 +18564,7 @@ export class FlowSurfacesService { }); if (!supportedFieldUses?.has(normalizedTargetUse)) { throwBadRequest( - `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldComponent '${normalizedTargetUse}'`, + `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldType '${normalizedTargetUse}'`, ); } return normalizedTargetUse; @@ -17717,20 +18575,23 @@ export class FlowSurfacesService { }); if (!contract?.fieldUse) { throwBadRequest( - `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldComponent '${normalizedTargetUse}'`, + `flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldType '${normalizedTargetUse}'`, ); } return contract.fieldUse; } - throwBadRequest(`flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldComponent`); + throwBadRequest(`flowSurfaces configure field wrapper '${normalizedWrapperUse}' does not support fieldType`); } - private async syncFieldComponentStepParams(wrapperNode: any, targetFieldUse: string, transaction?: any) { + private async syncFieldTypeStepParams(wrapperNode: any, targetFieldUse: string, transaction?: any) { const flowDomain = this.inferFieldComponentFlowDomain(wrapperNode?.use); if (!flowDomain || !wrapperNode?.uid) { return; } - const nextStepParams = _.merge({}, wrapperNode.stepParams || {}, { + const latestWrapper = await this.repository.findModelById(wrapperNode.uid, { + transaction, + }); + const nextStepParams = _.merge({}, latestWrapper?.stepParams || wrapperNode.stepParams || {}, { [flowDomain.flowKey]: { [flowDomain.stepKey]: { use: targetFieldUse, @@ -17769,16 +18630,41 @@ export class FlowSurfacesService { dataSourceKey: fieldSource.fieldSettingsInit?.dataSourceKey, enabledPackages: input.enabledPackages, }); + if (innerField.use === normalizedTargetUse) { + await this.repository.patch( + { + uid: innerField.uid, + stepParams: _.merge({}, innerField.stepParams || {}, { + fieldBinding: { + use: normalizedTargetUse, + }, + fieldSettings: { + init: fieldSource.fieldSettingsInit, + }, + }), + }, + { transaction: input.transaction }, + ); + return normalizedTargetUse; + } const defaultProps = getFieldBindingDefaultProps(input.wrapperNode?.use, normalizedTargetUse, fieldSource.field); + const fieldDefaults = getStandaloneFieldDefaults(normalizedTargetUse); const shouldPreservePatternFormField = input.wrapperNode?.use === 'PatternFormItemModel'; + const preservedPopupPageSubModel = + input.innerField?.subModels && Object.prototype.hasOwnProperty.call(input.innerField.subModels, 'page') + ? { + page: _.cloneDeep(input.innerField.subModels.page), + } + : {}; + const nextSubModels = { + ...(fieldDefaults.subModels ? _.cloneDeep(fieldDefaults.subModels) : {}), + ...preservedPopupPageSubModel, + }; const nextFieldNode = { uid: innerField.uid, use: shouldPreservePatternFormField ? 'PatternFormFieldModel' : normalizedTargetUse, props: _.pickBy( - { - ...(innerField.props || {}), - ...defaultProps, - }, + this.normalizeFieldPropsForUse(normalizedTargetUse, { ...(innerField.props || {}), ...defaultProps }), (value) => !_.isUndefined(value), ), decoratorProps: _.cloneDeep(innerField.decoratorProps || {}), @@ -17791,6 +18677,7 @@ export class FlowSurfacesService { init: fieldSource.fieldSettingsInit, }, }), + subModels: nextSubModels, }; await this.repository.patch(nextFieldNode, { transaction: input.transaction }); return normalizedTargetUse; diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/support-matrix.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/support-matrix.ts index ef3901394fc..1997b3976ed 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/support-matrix.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/flow-surfaces/support-matrix.ts @@ -246,6 +246,17 @@ export const FLOW_SURFACE_BLOCK_SUPPORT_MATRIX: FlowSurfaceBlockSupportEntry[] = readbackSupported: true, createSupported: true, }, + { + key: 'tree', + label: 'Tree', + modelUse: 'TreeBlockModel', + ownerPlugin: '@nocobase/plugin-block-tree', + topLevelAddable: true, + formalBuiltin: false, + fixtureCaptured: false, + readbackSupported: true, + createSupported: true, + }, ]; function hasFormalFlowSurfaceBlockKey( diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.examples.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.examples.ts index daed8581efc..62e3a30d28d 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.examples.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.examples.ts @@ -1488,17 +1488,29 @@ export const flowSurfaceExamples = { }, }, { - key: 'teamNotes', - type: 'markdown', - settings: { - content: '# Team notes', + key: 'userForm', + type: 'createForm', + resourceInit: { + dataSourceKey: 'main', + collectionName: 'users', + }, + fields: [ + { + key: 'rolesField', + fieldPath: 'roles', + fieldType: 'popupSubTable', + fields: ['title', 'name'], + }, + ], + fieldsLayout: { + rows: [['rolesField']], }, }, ], }, addFields: { target: { - uid: 'table-block-uid', + uid: 'create-form-block-uid', }, fields: [ { @@ -1523,13 +1535,12 @@ export const flowSurfaceExamples = { }, }, { - key: 'nickname', - fieldPath: 'nickname', - renderer: 'js', + key: 'rolesField', + fieldPath: 'roles', + fieldType: 'popupSubTable', + fields: ['title', 'name'], settings: { - label: 'Nickname (JS)', - code: 'return value;', - version: '1.0.0', + title: 'Roles', }, }, ], diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts index 3e2fc0980eb..f9b1bfcb675 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/swagger/flow-surfaces.ts @@ -122,9 +122,36 @@ const APPLY_BLUEPRINT_BLOCK_TYPE_ENUM = [ 'chart', 'actionPanel', 'jsBlock', + 'tree', ]; const APPROVAL_BLUEPRINT_BLOCK_TYPE_ENUM = [...APPROVAL_BLOCK_PUBLIC_KEYS]; const COMPOSE_BLOCK_TYPE_ENUM = [...APPLY_BLUEPRINT_BLOCK_TYPE_ENUM, ...APPROVAL_BLOCK_PUBLIC_KEYS]; +const RELATION_FIELD_TYPE_ENUM = [ + 'text', + 'select', + 'picker', + 'subForm', + 'subFormList', + 'subDetails', + 'subDetailsList', + 'subTable', + 'popupSubTable', +]; +const RELATION_FIELD_TYPE_SCHEMA = { + type: 'string', + enum: RELATION_FIELD_TYPE_ENUM, + description: 'Public relation field UI presentation type. This is not the collection field data type/interface.', +}; +const RELATION_TARGET_FIELDS_SCHEMA = { + type: 'array', + items: { type: 'string' }, + description: 'Relation target record fields used as sub-table columns or embedded sub-form/detail fields.', +}; +const RELATION_SELECTOR_FIELDS_SCHEMA = { + type: 'array', + items: { type: 'string' }, + description: 'Record picker selector table fields. Do not mix with fields on the same field object.', +}; const ADD_CHILD_TREE_TABLE_NOTE = '`addChild` is only valid when the live target `catalog.recordActions` exposes it, which normally means a table bound to a tree collection with `treeTable` enabled.'; const APPLY_BLUEPRINT_ADD_CHILD_NOTE = @@ -835,7 +862,7 @@ const actionDocs: Record = { tags: [FLOW_SURFACES_TAG], summary: 'Add a field wrapper and inner field under a field container', description: valuesCompatibilityNote( - 'Automatically derives the wrapper/inner-field combination from the container use and the field interface. It can also import a form template through `template`, using `reference` or `copy` mode for the target form grid. `fieldUse` is only kept as a compatibility check and is no longer an arbitrary creation entry. Direct add does not accept raw `wrapperProps` / `fieldProps` / `props` / `decoratorProps` / `stepParams` / `flowRegistry`. Use `settings` and reuse the public configuration semantics from `configure.changes` plus the catalog item/node `configureOptions`. Popup-capable fields can also pass `popup` directly to append a local popup subtree or `popup.template` to reuse a saved popup template in `reference` / `copy` mode. `popup.tryTemplate=true` asks the backend to auto-select a compatible popup template first, preferring the same relation when one exists and otherwise falling back to a compatible non-relation template. It may be combined with `popup.saveAsTemplate={ name, description }`: a hit reuses the matched template directly, while a miss requires explicit local `popup.blocks` so the fallback popup can be saved as a template reference. When `popup.template` is present, `popup.title` still applies, while local `popup.mode` / `popup.blocks` / `popup.layout` are accepted but ignored. If local openView is enabled but no popup content is provided, the server fills in the popup page/tab/grid shell automatically. Under approval forms, direct field creation preserves the `PatternFormFieldModel` inner node semantics and does not allow standalone `jsItem`.', + 'Automatically derives the wrapper/inner-field combination from the container use and the field interface. Relation fields can request a public `fieldType` such as `picker`, `subTable`, or `popupSubTable`, with optional flat `fields`, `selectorFields`, and `titleField`. Direct add does not accept raw `wrapperProps` / `fieldProps` / `props` / `decoratorProps` / `stepParams` / `flowRegistry` or internal field model keys. Use `settings` and reuse the public configuration semantics from `configure.changes` plus the catalog item/node `configureOptions`. Popup-capable fields can also pass `popup` directly to append a local popup subtree or `popup.template` to reuse a saved popup template in `reference` / `copy` mode. `popup.tryTemplate=true` asks the backend to auto-select a compatible popup template first, preferring the same relation when one exists and otherwise falling back to a compatible non-relation template. It may be combined with `popup.saveAsTemplate={ name, description }`: a hit reuses the matched template directly, while a miss requires explicit local `popup.blocks` so the fallback popup can be saved as a template reference. When `popup.template` is present, `popup.title` still applies, while local `popup.mode` / `popup.blocks` / `popup.layout` are accepted but ignored. If local openView is enabled but no popup content is provided, the server fills in the popup page/tab/grid shell automatically. Under approval forms, direct field creation preserves the `PatternFormFieldModel` inner node semantics and does not allow standalone `jsItem`.', ), requestBody: { required: true, @@ -969,7 +996,7 @@ const actionDocs: Record = { tags: [FLOW_SURFACES_TAG], summary: 'Add multiple blocks sequentially under the same target', description: valuesCompatibilityNote( - 'Creates multiple blocks sequentially under the same target. Each item may include `settings`, `defaultFilter`, `defaultActionSettings`, or `template`, but raw `props` / `decoratorProps` / `stepParams` / `flowRegistry` are not accepted. Direct `table` / `list` / `gridCard` / `calendar` / `kanban` items may use block-level `defaultFilter` to backfill the auto-created default filter action; backend runtime compatibility remains tolerant of omitted or empty values. Legacy `defaultActionSettings.filter.defaultFilter` is still supported for compatibility and wins when both are provided. Partial-success semantics apply: a failure in one item does not roll back the others. Results are returned in input order as `index/key/ok/result/error`, and each `error` always includes `message/type/code/status`.', + 'Creates multiple blocks sequentially under the same target. Each item may include `settings`, `defaultFilter`, `defaultActionSettings`, `template`, or inline `fields` / `fieldsLayout`, but raw `props` / `decoratorProps` / `stepParams` / `flowRegistry` are not accepted. Inline fields use the same public field semantics as compose/addField, including relation `fieldType`. Direct `table` / `list` / `gridCard` / `calendar` / `kanban` items may use block-level `defaultFilter` to backfill the auto-created default filter action; backend runtime compatibility remains tolerant of omitted or empty values. Legacy `defaultActionSettings.filter.defaultFilter` is still supported for compatibility and wins when both are provided. Partial-success semantics apply: a failure in one item does not roll back the others. Results are returned in input order as `index/key/ok/result/error`, and each `error` always includes `message/type/code/status`.', ), requestBody: requestBody('FlowSurfaceAddBlocksRequest', examples.addBlocks), responses: responses('FlowSurfaceAddBlocksResult'), @@ -2159,6 +2186,14 @@ const schemas = { fieldPath: { type: 'string', }, + fieldType: RELATION_FIELD_TYPE_SCHEMA, + fields: RELATION_TARGET_FIELDS_SCHEMA, + selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA, + titleField: { type: 'string' }, + openMode: { type: 'string', example: 'drawer' }, + popupSize: { type: 'string', example: 'medium' }, + pageSize: { type: 'number', example: 10 }, + showIndex: { type: 'boolean', example: true }, renderer: { type: 'string', enum: ['js'], @@ -3443,6 +3478,14 @@ const schemas = { associationPathName: { type: 'string' }, renderer: { type: 'string' }, type: { type: 'string' }, + fieldType: RELATION_FIELD_TYPE_SCHEMA, + fields: RELATION_TARGET_FIELDS_SCHEMA, + selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA, + titleField: { type: 'string' }, + openMode: { type: 'string', example: 'drawer' }, + popupSize: { type: 'string', example: 'medium' }, + pageSize: { type: 'number', example: 10 }, + showIndex: { type: 'boolean', example: true }, label: { type: 'string' }, target: { type: 'string', @@ -4361,6 +4404,11 @@ const schemas = { template: ref('FlowSurfaceBlockTemplateRef'), resource: ref('FlowSurfaceBlockResourceInput'), resourceInit: ref('FlowSurfaceResourceInit'), + fields: { + type: 'array', + items: ref('FlowSurfaceComposeFieldSpec'), + }, + fieldsLayout: ref('FlowSurfaceComposeLayout'), settings: ANY_OBJECT_SCHEMA, defaultFilter: { allOf: [ref('FlowSurfaceFilterGroup')], @@ -4452,10 +4500,14 @@ const schemas = { collectionName: { type: 'string', }, - fieldUse: { - type: 'string', - description: 'Optional compatibility check. The server infers the actual field use from catalog capabilities.', - }, + fieldType: RELATION_FIELD_TYPE_SCHEMA, + fields: RELATION_TARGET_FIELDS_SCHEMA, + selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA, + titleField: { type: 'string' }, + openMode: { type: 'string', example: 'drawer' }, + popupSize: { type: 'string', example: 'medium' }, + pageSize: { type: 'number', example: 10 }, + showIndex: { type: 'boolean', example: true }, defaultTargetUid: { type: 'string', }, @@ -4630,6 +4682,11 @@ const schemas = { template: ref('FlowSurfaceBlockTemplateRef'), resource: ref('FlowSurfaceBlockResourceInput'), resourceInit: ref('FlowSurfaceResourceInit'), + fields: { + type: 'array', + items: ref('FlowSurfaceComposeFieldSpec'), + }, + fieldsLayout: ref('FlowSurfaceComposeLayout'), settings: ANY_OBJECT_SCHEMA, defaultFilter: { allOf: [ref('FlowSurfaceFilterGroup')], @@ -4674,9 +4731,14 @@ const schemas = { collectionName: { type: 'string', }, - fieldUse: { - type: 'string', - }, + fieldType: RELATION_FIELD_TYPE_SCHEMA, + fields: RELATION_TARGET_FIELDS_SCHEMA, + selectorFields: RELATION_SELECTOR_FIELDS_SCHEMA, + titleField: { type: 'string' }, + openMode: { type: 'string', example: 'drawer' }, + popupSize: { type: 'string', example: 'medium' }, + pageSize: { type: 'number', example: 10 }, + showIndex: { type: 'boolean', example: true }, defaultTargetUid: { type: 'string', },