fix(client-v2): default tree filters to cascader (#10357)

This commit is contained in:
Zeke Zhang
2026-08-13 22:45:47 +08:00
committed by GitHub
parent 2f28488752
commit adcc3ea2cf
9 changed files with 404 additions and 7 deletions
@@ -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);
@@ -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,
};
}),
},
};
},
@@ -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<string, unknown>,
) {
const subModel = engine.createModel<FilterFormItemModel>({
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'],
},
],
});
});
});
@@ -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<DummyCollectionBlockModel>({
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<FilterFormItemModel>({
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({
@@ -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<CascadeSelectFieldModel>({
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');
});
});
@@ -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,
@@ -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');
});
});
@@ -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<string, any> = {}) {
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,
},
}),
);
@@ -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',