Merge branch 'next' into develop

This commit is contained in:
nocobase[bot]
2026-02-28 10:36:22 +00:00
3 changed files with 273 additions and 13 deletions
@@ -9,20 +9,20 @@
import {
Collection,
tExpr,
FieldModelRenderer,
FilterableItemModel,
FlowModelContext,
FormItem,
tExpr,
} from '@nocobase/flow-engine';
import { Empty } from 'antd';
import _, { cloneDeep, debounce, isEqual } from 'lodash';
import React from 'react';
import { CollectionBlockModel, FieldModel } from '../../base';
import { RecordSelectFieldModel } from '../../fields/AssociationFieldModel/RecordSelectFieldModel';
import { FilterManager } from '../filter-manager';
import { getAllDataModels, getDefaultOperator } from '../filter-manager/utils';
import { FilterFormFieldModel } from './fields';
import { FilterManager } from '../filter-manager';
import { normalizeFilterValueByOperator } from './valueNormalization';
const getAssociationTargetCollection = (field: any, collection?: Collection, model?: CollectionBlockModel) => {
@@ -89,6 +89,37 @@ const normalizeAssociationDefaultFilterValue = (value: any, fieldModel: any) =>
return pickValue(value);
};
const buildVirtualFilterCollectionField = (ctx: FlowModelContext, filterField: any) => {
if (!filterField) {
return;
}
const resolvedTitle = filterField?.title || filterField?.name;
const interfaceName = filterField?.interface;
const interfaceConfig = interfaceName
? // @ts-ignore
ctx?.dataSourceManager?.collectionFieldInterfaceManager?.getFieldInterface?.(interfaceName)
: undefined;
const result = {
...filterField,
title: resolvedTitle,
uiSchema: {
...(filterField?.uiSchema || {}),
title: resolvedTitle,
},
filterable: filterField?.filterable || interfaceConfig?.filterable,
isAssociationField:
typeof filterField?.isAssociationField === 'function'
? filterField.isAssociationField
: () => Boolean(filterField?.target),
getComponentProps:
typeof filterField?.getComponentProps === 'function' ? filterField.getComponentProps : () => ({}),
};
return result;
};
const buildFilterFormFieldItem = ({
model,
collection,
@@ -410,6 +441,16 @@ export class FilterFormItemModel extends FilterableItemModel<{
super.onInit(options);
// 创建防抖的 doFilter 方法,延迟 300ms
this.debouncedDoFilter = debounce(this.doFilter.bind(this), 300);
const initFilterField = this.getStepParams('filterFormItemSettings', 'init')?.filterField;
if (initFilterField) {
this.context.defineProperty('filterField', {
value: {
...initFilterField,
title: initFilterField?.title || initFilterField?.name,
},
});
}
}
onMount(): void {
@@ -548,7 +589,8 @@ FilterFormItemModel.registerFlow({
'x-decorator': 'FormItem',
'x-reactions': (field) => {
const model = ctx.model;
const originTitle = model.collectionField?.title || ctx.filterField?.title;
const originTitle =
model.collectionField?.title || ctx.filterField?.title || ctx.filterField?.name || model.fieldPath;
field.decoratorProps = {
...field.decoratorProps,
extra: model.context.t('Original field title: ') + originTitle,
@@ -559,11 +601,12 @@ FilterFormItemModel.registerFlow({
},
defaultParams: (ctx) => {
return {
label: ctx.collectionField?.title || ctx.filterField?.title,
label: ctx.collectionField?.title || ctx.filterField?.title || ctx.filterField?.name,
};
},
handler(ctx, params) {
if (params.label && params.label === ctx.collectionField?.title) {
const originTitle = ctx.collectionField?.title || ctx.filterField?.title || ctx.filterField?.name;
if (params.label && params.label === originTitle) {
ctx.model.setProps({ label: params.label });
} else {
ctx.model.setProps({ label: ctx.t(params.label, { ns: 'lm-flow-engine' }) });
@@ -575,7 +618,38 @@ FilterFormItemModel.registerFlow({
// },
init: {
async handler(ctx, params) {
const collectionField = ctx.model.collectionField;
const normalizedFilterField = params.filterField
? {
...params.filterField,
title: params.filterField?.title || params.filterField?.name,
}
: params.filterField;
ctx.model.context.defineProperty('filterField', {
value: normalizedFilterField,
});
ctx.model.setProps({
name: `${ctx.model.fieldPath}_${ctx.model.uid}`, // 确保每个字段的名称唯一
});
let collectionField = ctx.model.collectionField;
// SQL 图表筛选等场景下没有 collectionName/dataSourceKey,无法解析真实 collectionField。
// 此时用 filterField 元数据注入一个虚拟字段,避免误显示“字段已删除”占位。
const fieldSettingsInitParams = ctx.model.getFieldSettingsInitParams?.() || {};
const hasCollectionContext = !!(
// @ts-ignore
(fieldSettingsInitParams?.dataSourceKey && fieldSettingsInitParams?.collectionName)
);
if (!hasCollectionContext && !collectionField && normalizedFilterField) {
const virtualField = buildVirtualFilterCollectionField(ctx, normalizedFilterField);
if (virtualField) {
ctx.model.context.defineProperty('collectionField', {
value: virtualField,
});
collectionField = virtualField as any;
}
}
if (collectionField?.getComponentProps) {
const componentProps = collectionField.getComponentProps();
const fieldModel = ctx.model.subModels?.field;
@@ -589,12 +663,6 @@ FilterFormItemModel.registerFlow({
required: undefined,
});
}
ctx.model.setProps({
name: `${ctx.model.fieldPath}_${ctx.model.uid}`, // 确保每个字段的名称唯一
});
ctx.model.context.defineProperty('filterField', {
value: params.filterField,
});
},
},
@@ -150,4 +150,180 @@ describe('FilterFormItemModel defineChildren association fields', () => {
expect(filterItem.fieldPath).toBe('department.manager.name');
expect(filterItem.collectionField).toBeTruthy();
});
it('provides fallback field metadata for sql fields without collection context', async () => {
const engine = new FlowEngine();
engine.registerModels({
FilterFormItemModel,
NumberFieldModel,
});
const filterItem = engine.createModel<FilterFormItemModel>({
uid: 'sql-filter-item',
use: 'FilterFormItemModel',
stepParams: {
fieldSettings: {
init: {
fieldPath: 'id',
},
},
filterFormItemSettings: {
init: {
filterField: {
name: 'id',
title: 'id',
interface: 'number',
type: 'integer',
},
},
},
},
subModels: {
field: {
use: 'NumberFieldModel',
},
},
} as any);
await filterItem.dispatchEvent('beforeRender');
expect(filterItem.collectionField).toBeTruthy();
expect(filterItem.collectionField?.name).toBe('id');
expect(filterItem.collectionField?.interface).toBe('number');
});
it('keeps deleted-field detection when collection context exists', async () => {
const engine = new FlowEngine();
engine.registerModels({
FilterFormItemModel,
NumberFieldModel,
});
const ds = engine.dataSourceManager.getDataSource('main');
ds.addCollection({
name: 'users',
filterTargetKey: 'id',
fields: [{ name: 'id', type: 'integer', interface: 'number', filterable: { operators: [] } }],
});
const filterItem = engine.createModel<FilterFormItemModel>({
uid: 'deleted-field-item',
use: 'FilterFormItemModel',
stepParams: {
fieldSettings: {
init: {
dataSourceKey: 'main',
collectionName: 'users',
fieldPath: 'not_exists',
},
},
filterFormItemSettings: {
init: {
filterField: {
name: 'not_exists',
title: 'not_exists',
interface: 'number',
type: 'integer',
},
},
},
},
subModels: {
field: {
use: 'NumberFieldModel',
},
},
} as any);
await filterItem.dispatchEvent('beforeRender');
expect(filterItem.collectionField).toBeFalsy();
});
it('uses field name as fallback label when sql filter field title is missing', async () => {
const engine = new FlowEngine();
engine.registerModels({
FilterFormItemModel,
NumberFieldModel,
});
const filterItem = engine.createModel<FilterFormItemModel>({
uid: 'sql-filter-item-no-title',
use: 'FilterFormItemModel',
stepParams: {
fieldSettings: {
init: {
fieldPath: 'id',
},
},
filterFormItemSettings: {
init: {
filterField: {
name: 'id',
interface: 'number',
type: 'integer',
},
},
},
},
subModels: {
field: {
use: 'NumberFieldModel',
},
},
} as any);
await filterItem.dispatchEvent('beforeRender');
expect(filterItem.props.label).toBe('id');
});
it('applies getComponentProps from fallback sql field metadata', async () => {
const engine = new FlowEngine();
engine.registerModels({
FilterFormItemModel,
NumberFieldModel,
});
const filterItem = engine.createModel<FilterFormItemModel>({
uid: 'sql-filter-item-component-props',
use: 'FilterFormItemModel',
stepParams: {
fieldSettings: {
init: {
fieldPath: 'id',
},
},
filterFormItemSettings: {
init: {
filterField: {
name: 'id',
interface: 'number',
type: 'integer',
getComponentProps: () => ({
placeholder: 'sql-id',
allowMultiple: true,
multiple: true,
required: true,
rules: [{ required: true }],
}),
},
},
},
},
subModels: {
field: {
use: 'NumberFieldModel',
},
},
} as any);
await filterItem.dispatchEvent('beforeRender');
expect(filterItem.props.placeholder).toBe('sql-id');
expect(filterItem.props.allowMultiple).toBe(true);
expect(filterItem.props.multiple).toBe(true);
expect(filterItem.props.required).toBeUndefined();
expect(filterItem.props.rules).toBeUndefined();
});
});
@@ -14,6 +14,7 @@ import fse from 'fs-extra';
import path from 'path';
import Application from '../../application';
import PluginManager from '../plugin-manager';
import crypto from 'crypto';
class PackageUrls {
static items = {};
@@ -33,7 +34,22 @@ class PackageUrls {
const distExists = await fse.exists(dist);
if (distExists) {
const fsState = await fse.stat(distExists ? dist : pkgPath);
t = `?t=${fsState.mtime.getTime()}`;
const appKey = process.env.APP_KEY || '';
let version = '';
try {
const pkgJson = await fse.readJson(path.resolve(pkgPath, 'package.json'));
if (pkgJson && typeof pkgJson.version === 'string') {
version = pkgJson.version;
}
} catch (error) {
// Ignore errors reading package.json and fall back to empty version
}
const hash = crypto
.createHash('sha256')
.update(fsState.mtime.getTime() + appKey + version)
.digest('hex')
.slice(0, 8);
t = `?hash=${hash}`;
}
const cdnBaseUrl = process.env.CDN_BASE_URL.replace(/\/+$/, '');
const url = `${cdnBaseUrl}${'/static/plugins/'}${packageName}/${PLUGIN_CLIENT_ENTRY_FILE}${t}`;