Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-08-13 14:47:19 +00:00
12 changed files with 486 additions and 10 deletions
+2
View File
@@ -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
@@ -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<FormGridModel>({
use: 'FormGridModel',
uid: 'form-grid',
});
const fieldModel = engine.createModel<FormItemModel>({
use: 'FormItemModel',
uid: 'name-field',
props: { label: 'Name' },
subModels: {
field: {
use: 'FlowModel',
uid: 'name-field-component',
},
},
});
const jsItemModel = engine.createModel<JSItemModel>({
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',
@@ -120,11 +120,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,
@@ -346,8 +351,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 {
@@ -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',