From b4b8d00b71326a280ba308ebd7122409749d15ba Mon Sep 17 00:00:00 2001 From: chenos Date: Thu, 13 Aug 2026 00:48:33 +0800 Subject: [PATCH 1/3] chore: add BUILDX_NO_DEFAULT_ATTESTATIONS to push-docker job Set BUILDX_NO_DEFAULT_ATTESTATIONS environment variable for push-docker job. --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33dd909d560..c65d1f409ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -445,6 +445,8 @@ jobs: push-docker: if: ${{ always() && needs.prepare-release.result == 'success' && needs.prepare-release.outputs.run_docker_release == 'true' && (needs.publish-packages.result == 'success' || needs.publish-packages.result == 'skipped') }} runs-on: ubuntu-latest + env: + BUILDX_NO_DEFAULT_ATTESTATIONS: 1 needs: - prepare-release - publish-packages From 2f28488752537c2359245377544f89c47efddb12 Mon Sep 17 00:00:00 2001 From: gchust Date: Thu, 13 Aug 2026 13:30:43 +0000 Subject: [PATCH 2/3] fix: incorrect linkage rules for JS item in the form (#10363) --- .../linkageRules.subFormSetFieldProps.test.ts | 72 +++++++++++++++++++ .../src/flow/actions/linkageRules.tsx | 11 ++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts b/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts index 42d95253c90..c33089bbedb 100644 --- a/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +++ b/packages/core/client-v2/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts @@ -18,6 +18,7 @@ import { subFormFieldLinkageRules, subFormLinkageSetFieldProps, } from '../linkageRules'; +import { CreateFormModel, EditFormModel, FormGridModel, FormItemModel, JSItemModel } from '../../models'; const createSubFormFieldModel = ({ uid, @@ -1056,6 +1057,77 @@ describe('linkageSetFieldProps action', () => { expect(await screen.findByText('Published')).toBeTruthy(); }); + it.each([ + ['create form', CreateFormModel, linkageSetFieldProps, false], + ['edit form', EditFormModel, linkageSetFieldProps, false], + ['to-one subform', FlowModel, subFormLinkageSetFieldProps, false], + ['to-many subform row', FlowModel, subFormLinkageSetFieldProps, true], + ])('should exclude JS items from %s field state options', async (_, ModelClass, action, useGridFork) => { + const engine = new FlowEngine(); + engine.registerModels({ + CreateFormModel, + EditFormModel, + FormGridModel, + FormItemModel, + JSItemModel, + }); + const model = new ModelClass({ + flowEngine: engine, + uid: 'form-model', + use: ModelClass.name, + }) as FlowModel; + const grid = engine.createModel({ + use: 'FormGridModel', + uid: 'form-grid', + }); + const fieldModel = engine.createModel({ + use: 'FormItemModel', + uid: 'name-field', + props: { label: 'Name' }, + subModels: { + field: { + use: 'FlowModel', + uid: 'name-field-component', + }, + }, + }); + const jsItemModel = engine.createModel({ + use: 'JSItemModel', + uid: 'js-item', + props: { label: 'JS item' }, + }); + Object.assign(model.subModels, { + grid: useGridFork + ? grid.createFork({}, 'row-0') + : Object.assign(grid, { + subModels: { + ...grid.subModels, + items: [fieldModel, jsItemModel], + }, + }), + }); + Object.assign(model.subModels.grid.subModels, { + items: [fieldModel, jsItemModel], + }); + const ctx = model.context; + const Comp: any = action.uiSchema.value['x-component']; + const view = render( + React.createElement( + FlowSettingsContextProvider, + { value: ctx }, + React.createElement(Comp, { + value: { fields: [] }, + onChange: () => {}, + }), + ), + ); + + fireEvent.mouseDown(view.container.querySelector('.ant-select-selector') as Element); + + expect(await screen.findByText('Name')).toBeTruthy(); + expect(screen.queryByText('JS item')).toBeNull(); + }); + it('should only show options state for supported single field selection', () => { const supportedField: any = { uid: 'status-field', diff --git a/packages/core/client-v2/src/flow/actions/linkageRules.tsx b/packages/core/client-v2/src/flow/actions/linkageRules.tsx index c6f83d14228..02cb8c1bbce 100644 --- a/packages/core/client-v2/src/flow/actions/linkageRules.tsx +++ b/packages/core/client-v2/src/flow/actions/linkageRules.tsx @@ -124,11 +124,16 @@ const isActionFlowModel = (model: any): boolean => { return false; }; +const isFormFieldModel = (model: unknown): model is FlowModel => { + if (!model || typeof model !== 'object') return false; + return !!(model as FlowModel).subModels?.field; +}; + // 获取表单中所有字段的 model 实例的通用函数 const getFormFields = (ctx: any) => { try { const fieldModels = ctx.model?.subModels?.grid?.subModels?.items || []; - return fieldModels.map((model: any) => ({ + return fieldModels.filter(isFormFieldModel).map((model) => ({ label: model.props.label || model.props.name, value: model.uid, model, @@ -350,8 +355,8 @@ const FieldStateEditor = ({ const getFormFieldsByForkModel = (ctx: any) => { try { const fieldModels = ctx.model?.subModels?.grid?.subModels?.items || []; - return fieldModels.map((model: any) => { - const forkModel = Array.from(model.forks)[0] as any; + return fieldModels.filter(isFormFieldModel).map((model) => { + const forkModel = Array.from(model.forks)[0] as FlowModel | undefined; if (forkModel) { return { From adcc3ea2cf87eaeadf1ca6dec381ac502bbdc73c Mon Sep 17 00:00:00 2001 From: Zeke Zhang <958414905@qq.com> Date: Thu, 13 Aug 2026 22:45:47 +0800 Subject: [PATCH 3/3] fix(client-v2): default tree filters to cascader (#10357) --- .../filter-form/FilterFormGridModel.tsx | 5 + .../filter-form/FilterFormItemModel.tsx | 11 +- ...FilterFormGridModel.onModelCreated.test.ts | 67 ++++++++++ ...FilterFormItemModel.defineChildren.test.ts | 117 ++++++++++++++++++ ...FilterFormItemModel.getFilterValue.test.ts | 72 +++++++++++ .../CascadeSelectFieldModel.tsx | 34 ++++- ...rfaces.field-binding-registry.unit.test.ts | 58 +++++++++ ...field-default-bindings.integration.test.ts | 39 +++++- .../flow-surfaces/field-binding-registry.ts | 8 ++ 9 files changed, 404 insertions(+), 7 deletions(-) diff --git a/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx b/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx index 1e0d8714668..bc12a4dba56 100644 --- a/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx +++ b/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormGridModel.tsx @@ -81,6 +81,11 @@ export class FilterFormGridModel extends GridModel { return key; }; + const fieldModelValueKey = normalizeKey(subModel.subModels?.field?.props?.fieldNames?.value); + if (fieldModelValueKey) { + return fieldModelValueKey; + } + const filterTargetKey = normalizeKey(field?.targetCollection?.filterTargetKey); const fieldTargetKey = normalizeKey(field?.targetKey); diff --git a/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx b/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx index d4d11ec8cbd..3e94f4c692a 100644 --- a/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +++ b/packages/core/client-v2/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx @@ -899,10 +899,13 @@ FilterFormItemModel.registerFlow({ type: 'select', key: 'use', props: { - options: classes.map((model) => ({ - label: t(model.modelName), - value: model.modelName, - })), + options: classes.map((model) => { + const ModelClass = ctx.engine.getModelClass(model.modelName); + return { + label: t(ModelClass?.meta?.label || model.modelName), + value: model.modelName, + }; + }), }, }; }, diff --git a/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts b/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts index b937c560351..370c59a1296 100644 --- a/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts +++ b/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormGridModel.onModelCreated.test.ts @@ -15,6 +15,7 @@ import { FilterFormItemModel } from '../FilterFormItemModel'; import { FilterFormGridModel } from '../FilterFormGridModel'; import { InputFieldModel } from '../../../fields/InputFieldModel'; import { NumberFieldModel } from '../../../fields/NumberFieldModel'; +import { CascadeSelectFieldModel } from '../../../fields/AssociationFieldModel/CascadeSelectFieldModel'; import { RecordSelectFieldModel } from '../../../fields/AssociationFieldModel/RecordSelectFieldModel'; import { FilterFormRecordSelectFieldModel } from '../fields/FilterFormRecordSelectFieldModel'; @@ -42,6 +43,7 @@ function createEngine() { DummyCollectionBlockModel, InputFieldModel, NumberFieldModel, + CascadeSelectFieldModel, RecordSelectFieldModel, FilterFormRecordSelectFieldModel, }); @@ -83,6 +85,7 @@ function createFilterItemModel( dataBlockModel: DummyCollectionBlockModel, fieldPath: string, fieldModel?: string, + fieldProps?: Record, ) { const subModel = engine.createModel({ uid: `filter-item-${fieldPath}`, @@ -105,6 +108,7 @@ function createFilterItemModel( ? { field: { use: fieldModel, + props: fieldProps, }, } : undefined, @@ -350,4 +354,67 @@ describe('FilterFormGridModel onModelCreated', () => { ], }); }); + + it('uses the Cascader value key for both the filter value and connected field path', async () => { + const engine = createEngine(); + const ds = engine.dataSourceManager.getDataSource('main'); + + ds.addCollection({ + name: 'organizations', + template: 'tree', + filterTargetKey: 'id', + fields: [ + { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } }, + { name: 'code', type: 'string', interface: 'input', filterable: { operators: [] } }, + { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } }, + ], + }); + ds.addCollection({ + name: 'users', + filterTargetKey: 'id', + fields: [ + { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } }, + { + name: 'organization', + type: 'belongsTo', + interface: 'm2o', + target: 'organizations', + targetKey: 'code', + filterable: { operators: [] }, + }, + ], + }); + + const dataBlockModel = createDataBlockModel(engine); + const { gridModel, saveConnectFieldsConfig } = createGridModel(engine); + const subModel = createFilterItemModel(engine, dataBlockModel, 'organization', 'CascadeSelectFieldModel', { + fieldNames: { label: 'name', value: 'id' }, + value: { + id: 52, + code: 'ORG-52', + name: 'Leaf organization', + parent: { id: 1, code: 'ORG-1', name: 'Root organization', parent: null }, + }, + }); + subModel.mounted = true; + subModel.context.defineProperty('form', { + value: { + getFieldValue: () => subModel.subModels.field.props.value, + }, + }); + + await gridModel.onModelCreated(subModel); + + expect(subModel.getFilterValue()).toBe(52); + expect(saveConnectFieldsConfig).toHaveBeenCalledTimes(1); + const [, payload] = saveConnectFieldsConfig.mock.calls[0] as unknown as [unknown, unknown]; + expect(payload).toEqual({ + targets: [ + { + targetId: dataBlockModel.uid, + filterPaths: ['organization.id'], + }, + ], + }); + }); }); diff --git a/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts b/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts index 1e08fc2c9ce..8fb35ab5185 100644 --- a/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +++ b/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'; import '../../../../index'; import { FilterableItemModel, FlowEngine } from '@nocobase/flow-engine'; import { CollectionBlockModel } from '../../../base'; +import { CascadeSelectFieldModel } from '../../../fields/AssociationFieldModel/CascadeSelectFieldModel'; import { InputFieldModel } from '../../../fields/InputFieldModel'; import { NumberFieldModel } from '../../../fields/NumberFieldModel'; import { FilterFormRecordSelectFieldModel } from '../fields/FilterFormRecordSelectFieldModel'; @@ -432,6 +433,122 @@ describe('FilterFormItemModel defineChildren association fields', () => { expect(filterItem.subModels.field).toBeInstanceOf(TestCascaderFilterFieldModel); }); + it('uses Cascader by default for tree to-one associations and keeps Dropdown for other associations', async () => { + const engine = new FlowEngine(); + engine.registerModels({ + FilterFormItemModel: FilterFormItemModel as any, + DummyCollectionBlockModel, + CascadeSelectFieldModel, + FilterFormRecordSelectFieldModel, + }); + + const ds = engine.dataSourceManager.getDataSource('main'); + ds?.addCollection({ + name: 'organizations', + template: 'tree', + filterTargetKey: 'id', + titleField: 'name', + fields: [ + { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } }, + { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } }, + ], + }); + ds?.addCollection({ + name: 'departments', + filterTargetKey: 'id', + fields: [ + { name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } }, + { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } }, + ], + }); + ds?.addCollection({ + name: 'users', + filterTargetKey: 'id', + fields: [ + { + name: 'organization', + title: 'Organization', + type: 'belongsTo', + interface: 'm2o', + target: 'organizations', + filterable: { operators: [] }, + }, + { + name: 'department', + title: 'Department', + type: 'belongsTo', + interface: 'm2o', + target: 'departments', + filterable: { operators: [] }, + }, + { + name: 'organizations', + title: 'Organizations', + type: 'belongsToMany', + interface: 'm2m', + target: 'organizations', + filterable: { operators: [] }, + }, + ], + }); + + const model = engine.createModel({ + uid: 'users-tree-association-block', + use: 'DummyCollectionBlockModel', + stepParams: { + resourceSettings: { + init: { + dataSourceKey: 'main', + collectionName: 'users', + }, + }, + }, + }); + + const children = (await FilterFormItemModel.defineChildren({ + blockGridModel: { + filterSubModels: (_key: string, predicate: (item: any) => boolean) => [model].filter(predicate), + }, + t: (value: string) => value, + } as any)) as any[]; + const groups = await children[0].children(); + const fieldsGroup = groups.find((group: any) => group.key === 'fields'); + const organizationItem = fieldsGroup?.children?.find((item: any) => item.key === 'organization'); + const departmentItem = fieldsGroup?.children?.find((item: any) => item.key === 'department'); + const organizationsItem = fieldsGroup?.children?.find((item: any) => item.key === 'organizations'); + + const organizationCreateOptions = await organizationItem.createModelOptions(); + const departmentCreateOptions = await departmentItem.createModelOptions(); + const organizationsCreateOptions = await organizationsItem.createModelOptions(); + + expect(organizationCreateOptions.subModels.field.use).toBe('CascadeSelectFieldModel'); + expect(organizationCreateOptions.subModels.field.props).toMatchObject({ + fieldNames: { label: 'name', value: 'id' }, + }); + expect(departmentCreateOptions.subModels.field.use).toBe('FilterFormRecordSelectFieldModel'); + expect(organizationsCreateOptions.subModels.field.use).toBe('FilterFormRecordSelectFieldModel'); + + const filterItem = engine.createModel({ + uid: 'filter-item-tree-organization', + ...organizationCreateOptions, + }); + const modelStep = filterItem.getFlow('filterFormItemSettings')?.steps?.model as any; + const settingsContext = { + engine, + collectionField: ds?.getCollection('users')?.getField('organization'), + t: (value: string) => engine.translate(value), + }; + const options = modelStep.uiMode(settingsContext).props.options; + + expect(modelStep.hideInSettings(settingsContext)).not.toBe(true); + expect(options).toEqual( + expect.arrayContaining([ + { label: 'Dropdown select', value: 'FilterFormRecordSelectFieldModel' }, + { label: 'Cascader', value: 'CascadeSelectFieldModel' }, + ]), + ); + }); + it('provides fallback field metadata for sql fields without collection context', async () => { const engine = new FlowEngine(); engine.registerModels({ diff --git a/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts b/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts index 4fccaabb744..3279b179ee4 100644 --- a/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts +++ b/packages/core/client-v2/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.getFilterValue.test.ts @@ -9,6 +9,8 @@ import { describe, expect, it, vi } from 'vitest'; import '../../../../index'; +import { FlowEngine } from '@nocobase/flow-engine'; +import { CascadeSelectFieldModel } from '../../../fields/AssociationFieldModel/CascadeSelectFieldModel'; import { FilterFormItemModel } from '../FilterFormItemModel'; describe('FilterFormItemModel getFilterValue', () => { @@ -174,4 +176,74 @@ describe('FilterFormItemModel getFilterValue', () => { const value = FilterFormItemModel.prototype.getFilterValue.call(model as any); expect(value).toBe(25); }); + + it('uses the tree Cascader leaf target key with the equality operator', () => { + const engine = new FlowEngine(); + engine.registerModels({ CascadeSelectFieldModel }); + + const ds = engine.dataSourceManager.getDataSource('main'); + ds?.addCollection({ + name: 'organizations', + template: 'tree', + filterTargetKey: 'code', + fields: [ + { name: 'code', type: 'string', interface: 'input', filterable: { operators: [] } }, + { name: 'name', type: 'string', interface: 'input', filterable: { operators: [] } }, + ], + }); + ds?.addCollection({ + name: 'users', + fields: [ + { + name: 'organization', + type: 'belongsTo', + interface: 'm2o', + target: 'organizations', + targetKey: 'code', + filterable: { operators: [] }, + }, + ], + }); + + const fieldModel = engine.createModel({ + uid: 'tree-cascader-filter-field', + use: 'CascadeSelectFieldModel', + stepParams: { + fieldSettings: { + init: { + dataSourceKey: 'main', + collectionName: 'users', + fieldPath: 'organization', + }, + }, + }, + props: { + fieldNames: { label: 'name', value: 'code' }, + value: { + code: 'leaf', + name: 'Leaf organization', + parent: { code: 'root', name: 'Root organization', parent: null }, + }, + }, + }); + const model = { + mounted: true, + props: { name: 'organization' }, + subModels: { field: fieldModel }, + context: { + form: { + getFieldValue: vi.fn(() => fieldModel.props.value), + }, + }, + getDefaultValue: vi.fn(() => undefined), + getStepParams: vi.fn(() => undefined), + getCurrentOperatorMeta: vi.fn(() => null), + normalizeAssociationFilterValue: FilterFormItemModel.prototype.normalizeAssociationFilterValue, + }; + + const value = FilterFormItemModel.prototype.getFilterValue.call(model as unknown as FilterFormItemModel); + + expect(fieldModel.operator).toBe('$eq'); + expect(value).toBe('leaf'); + }); }); diff --git a/packages/core/client-v2/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx b/packages/core/client-v2/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx index 38cd9cdc749..5de67784048 100644 --- a/packages/core/client-v2/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx +++ b/packages/core/client-v2/src/flow/models/fields/AssociationFieldModel/CascadeSelectFieldModel.tsx @@ -8,7 +8,13 @@ */ import { Cascader, Space, Button } from 'antd'; -import { CollectionField, EditableItemModel, tExpr, MultiRecordResource } from '@nocobase/flow-engine'; +import { + CollectionField, + EditableItemModel, + FilterableItemModel, + MultiRecordResource, + tExpr, +} from '@nocobase/flow-engine'; import { DeleteOutlined } from '@ant-design/icons'; import { css, cx } from '@emotion/css'; import { debounce, castArray, omit, last, isEqual } from 'lodash'; @@ -31,6 +37,23 @@ function normalizeLabelKeys(labelKeyOrKeys?: string | string[]): string[] { return Array.from(new Set(list.filter((item): item is string => !!item && typeof item === 'string'))); } +function normalizeCascadeFieldKey(key: unknown): string | undefined { + if (typeof key === 'string' && key) { + return key; + } + if (Array.isArray(key) && key.length === 1 && typeof key[0] === 'string') { + return key[0]; + } + return undefined; +} + +function getDefaultCascadeFieldNames(field: CollectionField) { + const targetCollection = field?.targetCollection; + const value = normalizeCascadeFieldKey(targetCollection?.filterTargetKey) || 'id'; + const label = targetCollection?.titleCollectionField?.name || value; + return { label, value }; +} + function getRecordDisplayLabel(item: any, labelKeyOrKeys?: string | string[]): string | null { if (!isPlainRecord(item)) return null; const labelKeys = normalizeLabelKeys(labelKeyOrKeys); @@ -815,6 +838,15 @@ EditableItemModel.bindModelToInterface('CascadeSelectFieldModel', ['m2o', 'o2o', order: 60, }); +FilterableItemModel.bindModelToInterface('CascadeSelectFieldModel', ['m2o', 'o2o', 'oho', 'obo'], { + when: (ctx, field) => field.targetCollection?.template === 'tree', + isDefault: true, + order: 60, + defaultProps: (ctx, field) => ({ + fieldNames: getDefaultCascadeFieldNames(field), + }), +}); + EditableItemModel.bindModelToInterface('CascadeSelectListFieldModel', ['m2m', 'o2m', 'mbm'], { when: (ctx, field) => field.targetCollection?.template === 'tree', isDefault: true, 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 6a5f45fd097..113e75e2c65 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 @@ -325,4 +325,62 @@ describe('flowSurfaces field binding registry', () => { ), ).toEqual(['DisplayTextFieldModel', 'DisplaySubListFieldModel', 'DisplaySubTableFieldModel']); }); + + it('should use Cascader for tree to-one filter fields without changing other relation defaults', () => { + const treeToOneField = { + interface: 'm2o', + targetCollection: { + template: 'tree', + }, + }; + const generalToOneField = { + interface: 'm2o', + targetCollection: { + template: 'general', + }, + }; + const treeToManyField = { + interface: 'm2m', + targetCollection: { + template: 'tree', + }, + }; + + expect( + resolveSupportedFieldCapability({ + containerUse: 'FilterFormBlockModel', + field: treeToOneField, + }), + ).toMatchObject({ + wrapperUse: 'FilterFormItemModel', + fieldUse: 'CascadeSelectFieldModel', + inferredFieldUse: 'CascadeSelectFieldModel', + }); + expect( + resolveSupportedFieldCapability({ + containerUse: 'FilterFormBlockModel', + field: treeToOneField, + requestedFieldUse: 'CascadeSelectFieldModel', + }).fieldUse, + ).toBe('CascadeSelectFieldModel'); + expect( + getSupportedFieldComponentUseSet({ + containerUse: 'FilterFormBlockModel', + field: treeToOneField, + })?.has('CascadeSelectFieldModel'), + ).toBe(true); + + expect( + resolveSupportedFieldCapability({ + containerUse: 'FilterFormBlockModel', + field: generalToOneField, + }).fieldUse, + ).toBe('FilterFormRecordSelectFieldModel'); + expect( + resolveSupportedFieldCapability({ + containerUse: 'FilterFormBlockModel', + field: treeToManyField, + }).fieldUse, + ).toBe('FilterFormRecordSelectFieldModel'); + }); }); diff --git a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-default-bindings.integration.test.ts b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-default-bindings.integration.test.ts index 2eaab099b03..1a4b904636f 100644 --- a/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-default-bindings.integration.test.ts +++ b/packages/plugins/@nocobase/plugin-flow-engine/src/server/__tests__/flow-surfaces.field-default-bindings.integration.test.ts @@ -117,6 +117,10 @@ describe('flowSurfaces field default bindings', () => { use: 'FilterFormItemModel', fieldUse: 'NumberFieldModel', }); + expect(findCatalogField(filterCatalog, 'organization')).toMatchObject({ + use: 'FilterFormItemModel', + fieldUse: 'CascadeSelectFieldModel', + }); }); it('should use corrected default field models for addField addFields and compose write paths', async () => { @@ -138,6 +142,10 @@ describe('flowSurfaces field default bindings', () => { dataSourceKey: 'main', collectionName, }); + const filterFormUid = await addBlock(rootAgent, page.tabSchemaUid, 'filterForm', { + dataSourceKey: 'main', + collectionName, + }); const displayField = await addField(rootAgent, tableUid, 'status'); expect((await getSurface(rootAgent, { uid: displayField.fieldUid })).tree.use).toBe('DisplayEnumFieldModel'); @@ -145,6 +153,13 @@ describe('flowSurfaces field default bindings', () => { const radioField = await addField(rootAgent, formUid, 'stage'); expect((await getSurface(rootAgent, { uid: radioField.fieldUid })).tree.use).toBe('RadioGroupFieldModel'); + const treeAssociationFilterField = await addField(rootAgent, filterFormUid, 'organization', { + defaultTargetUid: tableUid, + }); + expect((await getSurface(rootAgent, { uid: treeAssociationFilterField.fieldUid })).tree.use).toBe( + 'CascadeSelectFieldModel', + ); + const addFieldsRes = getData( await rootAgent.resource('flowSurfaces').addFields({ values: { @@ -217,6 +232,17 @@ function getData(response: any) { async function createFieldDefaultBindingCollection(rootAgent: any, app: MockServer, suffix: string) { const collectionName = `field_default_bindings_${suffix}_${Date.now()}`; + const treeCollectionName = `${collectionName}_organizations`; + const treeApplyResponse = await rootAgent.resource('collections').apply({ + values: { + name: treeCollectionName, + template: 'tree', + titleField: 'name', + fields: [{ name: 'name', type: 'string', interface: 'input' }], + }, + }); + expect(treeApplyResponse.status).toBe(200); + const applyResponse = await rootAgent.resource('collections').apply({ values: { name: collectionName, @@ -266,13 +292,21 @@ async function createFieldDefaultBindingCollection(rootAgent: any, app: MockServ type: 'integer', interface: 'number', }, + { + name: 'organization', + type: 'belongsTo', + interface: 'm2o', + target: treeCollectionName, + foreignKey: 'organizationId', + }, ], }, }); expect(applyResponse.status).toBe(200); await waitForFixtureCollectionsReady(app.db as any, { - [collectionName]: ['title', 'status', 'stage', 'tags', 'enabled', 'rank'], + [collectionName]: ['title', 'status', 'stage', 'tags', 'enabled', 'rank', 'organizationId'], + [treeCollectionName]: ['name', 'parentId'], }); return collectionName; @@ -304,7 +338,7 @@ async function addBlock(rootAgent: any, targetUid: string, type: string, resourc ).uid; } -async function addField(rootAgent: any, targetUid: string, fieldPath: string) { +async function addField(rootAgent: any, targetUid: string, fieldPath: string, extraValues: Record = {}) { return getData( await rootAgent.resource('flowSurfaces').addField({ values: { @@ -312,6 +346,7 @@ async function addField(rootAgent: any, targetUid: string, fieldPath: string) { uid: targetUid, }, fieldPath, + ...extraValues, }, }), ); 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 cb2dbe756ce..781b0fd6f32 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 @@ -116,6 +116,14 @@ function selectPreferredDefaultRule(rules: FlowSurfaceFieldBindingRuleRecord[]) } const FIELD_BINDING_RULE_DEFINITIONS = [ + { + scope: 'filter', + modelClassName: 'CascadeSelectFieldModel', + interfaces: ['m2o', 'o2o', 'oho', 'obo'], + isDefault: true, + order: 60, + when: ({ targetCollection }) => getCollectionTemplate(targetCollection) === 'tree', + }, { scope: 'display', modelClassName: 'DisplayPreviewFieldModel',