mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
fix(client): support filter form variables (#9474)
* fix(client): support filter form variables * fix(client): address filter form review * fix(client): sync filter form values * fix(client): emit final filter form values
This commit is contained in:
@@ -122,12 +122,11 @@ const FilterFormDefaultValuesUI = observer(
|
||||
rootCollection={getCollectionFromModel(ctx.model)}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
fixedMode="default"
|
||||
showCondition={false}
|
||||
showValueEditorWhenNoField
|
||||
getValueInputProps={getValueInputProps}
|
||||
isTitleFieldCandidate={isTitleFieldCandidate}
|
||||
onSyncAssociationTitleField={onSyncAssociationTitleField}
|
||||
enableDateVariableAsConstant
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import { FormButtonGroup } from '@formily/antd-v5';
|
||||
import type { CollectionField, PropertyMeta, PropertyMetaFactory } from '@nocobase/flow-engine';
|
||||
import {
|
||||
AddSubModelButton,
|
||||
DndProvider,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
FlowSettingsButton,
|
||||
} from '@nocobase/flow-engine';
|
||||
import { Form } from 'antd';
|
||||
import { isEqual } from 'lodash';
|
||||
import React from 'react';
|
||||
import { commonConditionHandler, ConditionBuilder } from '../../../components/ConditionBuilder';
|
||||
import {
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
} from '../../../internal/utils/saveStepParamsWithSubModels';
|
||||
import { BlockSceneEnum, FilterBlockModel } from '../../base';
|
||||
import { FormComponent } from '../../blocks/form/FormBlockModel';
|
||||
import { evaluateCondition } from '../form/value-runtime/conditions';
|
||||
import { isEmptyValue } from '../form/value-runtime/utils';
|
||||
import { FilterManager, type RefreshTargetsByFilterOptions } from '../filter-manager/FilterManager';
|
||||
import { FilterFormItemModel } from './FilterFormItemModel';
|
||||
@@ -39,6 +42,177 @@ import { FormItemModel } from '../form';
|
||||
import { getDefaultOperator } from '../filter-manager/utils';
|
||||
import { normalizeFilterValueByOperator } from './valueNormalization';
|
||||
|
||||
const RELATION_FIELD_TYPES = ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'belongsToArray'];
|
||||
const NUMERIC_FIELD_TYPES = ['integer', 'float', 'double', 'decimal'];
|
||||
|
||||
function getFilterFormFieldMetaType(field: CollectionField) {
|
||||
if (RELATION_FIELD_TYPES.includes(field.type)) {
|
||||
return 'object';
|
||||
}
|
||||
|
||||
if (NUMERIC_FIELD_TYPES.includes(field.type)) {
|
||||
return 'number';
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return 'boolean';
|
||||
case 'json':
|
||||
return 'object';
|
||||
case 'array':
|
||||
return 'array';
|
||||
default:
|
||||
return 'string';
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowFilterFormFieldMeta(field: CollectionField) {
|
||||
return Boolean(field?.interface);
|
||||
}
|
||||
|
||||
function createFilterFormFieldMeta(field: CollectionField): PropertyMeta {
|
||||
const baseMeta = {
|
||||
title: field.title || field.name,
|
||||
interface: field.interface,
|
||||
options: field.options,
|
||||
uiSchema: field.uiSchema || {},
|
||||
};
|
||||
|
||||
if (!field.isAssociationField?.()) {
|
||||
return {
|
||||
type: getFilterFormFieldMetaType(field),
|
||||
...baseMeta,
|
||||
};
|
||||
}
|
||||
|
||||
const targetCollection = field.targetCollection;
|
||||
if (!targetCollection) {
|
||||
return {
|
||||
type: 'object',
|
||||
...baseMeta,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
...baseMeta,
|
||||
properties: async () => {
|
||||
const properties: Record<string, PropertyMeta> = {};
|
||||
targetCollection.fields.forEach((subField) => {
|
||||
if (shouldShowFilterFormFieldMeta(subField)) {
|
||||
properties[subField.name] = createFilterFormFieldMeta(subField);
|
||||
}
|
||||
});
|
||||
return properties;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getFilterFormItemFieldName(itemModel: any) {
|
||||
const name = itemModel?.props?.name;
|
||||
if (typeof name === 'string' && name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return itemModel?.fieldPath && itemModel?.uid ? `${itemModel.fieldPath}_${itemModel.uid}` : undefined;
|
||||
}
|
||||
|
||||
function toFilterByTk(value: any, primaryKey: string | string[] | undefined) {
|
||||
if (value == null) return undefined;
|
||||
if (Array.isArray(primaryKey)) {
|
||||
if (typeof value !== 'object') return undefined;
|
||||
const filterByTk: Record<string, any> = {};
|
||||
for (const key of primaryKey) {
|
||||
const item = value?.[key];
|
||||
if (item == null) return undefined;
|
||||
filterByTk[key] = item;
|
||||
}
|
||||
return filterByTk;
|
||||
}
|
||||
if (typeof value !== 'object') return value;
|
||||
const key = Array.isArray(primaryKey) ? primaryKey[0] : primaryKey;
|
||||
return key ? value?.[key] : value?.id;
|
||||
}
|
||||
|
||||
function setValueByPath(target: Record<string, any>, path: string, value: any) {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
if (!segments.length) return;
|
||||
|
||||
let cursor = target;
|
||||
segments.forEach((segment, index) => {
|
||||
if (index === segments.length - 1) {
|
||||
cursor[segment] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cursor[segment] || typeof cursor[segment] !== 'object' || Array.isArray(cursor[segment])) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment];
|
||||
});
|
||||
}
|
||||
|
||||
function setMetaByPath(target: Record<string, PropertyMeta>, path: string, meta: PropertyMeta) {
|
||||
const segments = path.split('.').filter(Boolean);
|
||||
if (!segments.length) return;
|
||||
|
||||
let cursor = target;
|
||||
segments.forEach((segment, index) => {
|
||||
if (index === segments.length - 1) {
|
||||
cursor[segment] = meta;
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cursor[segment];
|
||||
if (!current || typeof current !== 'object') {
|
||||
cursor[segment] = {
|
||||
type: 'object',
|
||||
title: segment,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
const properties = cursor[segment].properties;
|
||||
if (!properties || typeof properties === 'function') {
|
||||
cursor[segment].properties = {};
|
||||
}
|
||||
cursor = cursor[segment].properties as Record<string, PropertyMeta>;
|
||||
});
|
||||
}
|
||||
|
||||
function getFilterFormValues(form: any, items: any[]) {
|
||||
const formValues = form?.getFieldsValue?.() || {};
|
||||
const values = { ...formValues };
|
||||
|
||||
for (const itemModel of items) {
|
||||
const fieldName = getFilterFormItemFieldName(itemModel);
|
||||
if (!fieldName || !fieldName.includes('.') || !(fieldName in formValues)) {
|
||||
continue;
|
||||
}
|
||||
setValueByPath(values, fieldName, formValues[fieldName]);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
function isFilterFormFieldSubPath(fieldName: string, subPath: string) {
|
||||
return subPath === fieldName || subPath.startsWith(`${fieldName}.`) || subPath.startsWith(`${fieldName}[`);
|
||||
}
|
||||
|
||||
function isFilterFormFieldDeepSubPath(fieldName: string, subPath: string) {
|
||||
return subPath.startsWith(`${fieldName}.`) || subPath.startsWith(`${fieldName}[`);
|
||||
}
|
||||
|
||||
function findFilterFormItemByVariableSubPath(items: any[], subPath: string) {
|
||||
if (!subPath) return null;
|
||||
|
||||
return (
|
||||
items.find((itemModel) => {
|
||||
const fieldName = getFilterFormItemFieldName(itemModel);
|
||||
return fieldName && isFilterFormFieldSubPath(fieldName, subPath);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
export class FilterFormBlockModel extends FilterBlockModel<{
|
||||
subModels: {
|
||||
grid: any; // Replace with actual type if available
|
||||
@@ -55,6 +229,8 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
||||
private removeTargetBlockListener?: () => void;
|
||||
private initialDefaultsPromise?: Promise<void>;
|
||||
private initialRefreshHandledTargetIds = new Set<string>();
|
||||
private lastDefaultValueByFieldName = new Map<string, any>();
|
||||
private defaultValuesRefreshSeq = 0;
|
||||
|
||||
get form() {
|
||||
return this.context.form;
|
||||
@@ -64,10 +240,98 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
||||
return 'Filter form';
|
||||
}
|
||||
|
||||
protected createFormValuesMetaFactory(): PropertyMetaFactory {
|
||||
const factory: PropertyMetaFactory = async () => ({
|
||||
type: 'object',
|
||||
title: this.translate('Current form'),
|
||||
properties: async () => {
|
||||
const properties: Record<string, PropertyMeta> = {};
|
||||
const items = this.subModels?.grid?.subModels?.items || [];
|
||||
|
||||
for (const itemModel of items) {
|
||||
const fieldName = getFilterFormItemFieldName(itemModel);
|
||||
const collectionField = itemModel?.subModels?.field?.context?.collectionField || itemModel?.collectionField;
|
||||
if (!fieldName || !collectionField || !shouldShowFilterFormFieldMeta(collectionField)) {
|
||||
continue;
|
||||
}
|
||||
setMetaByPath(properties, fieldName, createFilterFormFieldMeta(collectionField));
|
||||
}
|
||||
|
||||
return properties;
|
||||
},
|
||||
buildVariablesParams: () => {
|
||||
const formValues = this.form?.getFieldsValue?.() || {};
|
||||
const items = this.subModels?.grid?.subModels?.items || [];
|
||||
const params: Record<string, any> = {};
|
||||
|
||||
for (const itemModel of items) {
|
||||
const fieldName = getFilterFormItemFieldName(itemModel);
|
||||
const collectionField = itemModel?.subModels?.field?.context?.collectionField || itemModel?.collectionField;
|
||||
if (!fieldName || !collectionField?.isAssociationField?.()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetCollection = collectionField.targetCollection;
|
||||
const target = collectionField.target;
|
||||
if (!targetCollection || !target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fieldValue = formValues[fieldName];
|
||||
const primaryKey = targetCollection.filterTargetKey;
|
||||
if (Array.isArray(fieldValue)) {
|
||||
const filterByTk = fieldValue.map((item) => toFilterByTk(item, primaryKey)).filter((item) => item != null);
|
||||
if (filterByTk.length) {
|
||||
setValueByPath(params, fieldName, {
|
||||
collection: target,
|
||||
dataSourceKey: targetCollection.dataSourceKey,
|
||||
filterByTk,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const filterByTk = toFilterByTk(fieldValue, primaryKey);
|
||||
if (filterByTk != null) {
|
||||
setValueByPath(params, fieldName, {
|
||||
collection: target,
|
||||
dataSourceKey: targetCollection.dataSourceKey,
|
||||
filterByTk,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
});
|
||||
factory.title = this.translate('Current form');
|
||||
return factory;
|
||||
}
|
||||
|
||||
useHooksBeforeRender() {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [form] = Form.useForm();
|
||||
this.context.defineProperty('form', { get: () => form, cache: false });
|
||||
this.context.defineProperty('formValues', {
|
||||
get: () => getFilterFormValues(this.form, this.subModels?.grid?.subModels?.items || []),
|
||||
cache: false,
|
||||
meta: this.createFormValuesMetaFactory(),
|
||||
resolveOnServer: (subPath: string) => {
|
||||
const items = this.subModels?.grid?.subModels?.items || [];
|
||||
const itemModel = findFilterFormItemByVariableSubPath(items, subPath);
|
||||
if (!itemModel) return false;
|
||||
|
||||
const fieldName = getFilterFormItemFieldName(itemModel);
|
||||
const collectionField = itemModel?.subModels?.field?.context?.collectionField || itemModel?.collectionField;
|
||||
return Boolean(
|
||||
fieldName &&
|
||||
isFilterFormFieldDeepSubPath(fieldName, subPath) &&
|
||||
collectionField?.isAssociationField?.() &&
|
||||
collectionField?.targetCollection,
|
||||
);
|
||||
},
|
||||
serverOnlyWhenContextParams: true,
|
||||
});
|
||||
}
|
||||
|
||||
async saveStepParams() {
|
||||
@@ -164,14 +428,38 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
||||
}
|
||||
}
|
||||
|
||||
async applyFormDefaultValues(options?: { force?: boolean }) {
|
||||
private canApplyFormDefaultValue(name: string, current: any, force?: boolean) {
|
||||
if (force) return true;
|
||||
if (isEmptyValue(current)) return true;
|
||||
if (!this.lastDefaultValueByFieldName.has(name)) return false;
|
||||
return isEqual(current, this.lastDefaultValueByFieldName.get(name));
|
||||
}
|
||||
|
||||
private async matchDefaultValueCondition(condition: any) {
|
||||
if (!condition) return true;
|
||||
|
||||
let resolvedCondition = condition;
|
||||
try {
|
||||
const nextCondition = await (this.context as any).resolveJsonTemplate?.(condition);
|
||||
if (typeof nextCondition !== 'undefined') {
|
||||
resolvedCondition = nextCondition;
|
||||
}
|
||||
} catch {
|
||||
resolvedCondition = condition;
|
||||
}
|
||||
|
||||
return evaluateCondition(this.context, resolvedCondition);
|
||||
}
|
||||
|
||||
async applyFormDefaultValues(options?: { force?: boolean; refreshSeq?: number }) {
|
||||
const appliedValues: Record<string, any> = {};
|
||||
const form = this.form;
|
||||
if (!form) return;
|
||||
if (!form) return appliedValues;
|
||||
|
||||
const force = options?.force === true;
|
||||
const params = this.getStepParams?.('formFilterBlockModelSettings', 'defaultValues');
|
||||
const rules = (params?.value || []) as any[];
|
||||
if (!Array.isArray(rules) || rules.length === 0) return;
|
||||
if (!Array.isArray(rules) || rules.length === 0) return appliedValues;
|
||||
|
||||
const resolveValue = async (raw: any) => {
|
||||
// RunJS support
|
||||
@@ -187,7 +475,8 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
||||
for (const rule of rules) {
|
||||
if (!rule || typeof rule !== 'object') continue;
|
||||
if (rule.enable === false) continue;
|
||||
if (rule.mode && String(rule.mode) !== 'default') continue;
|
||||
if (!(await this.matchDefaultValueCondition(rule.condition))) continue;
|
||||
if (options?.refreshSeq && options.refreshSeq !== this.defaultValuesRefreshSeq) return appliedValues;
|
||||
|
||||
const targetPath = rule.targetPath ? String(rule.targetPath).trim() : '';
|
||||
const fieldUid = rule.field ? String(rule.field).trim() : '';
|
||||
@@ -202,20 +491,54 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
||||
if (!name) continue;
|
||||
|
||||
const current = (form as any).getFieldValue?.(name);
|
||||
if (!force && !isEmptyValue(current)) continue;
|
||||
|
||||
const resolved = await resolveValue(rule.value);
|
||||
if (options?.refreshSeq && options.refreshSeq !== this.defaultValuesRefreshSeq) return appliedValues;
|
||||
if (typeof resolved === 'undefined') continue;
|
||||
|
||||
const operator = getDefaultOperator(itemModel as any);
|
||||
const normalized = normalizeFilterValueByOperator(operator, resolved);
|
||||
const mode = String(rule.mode || 'default') === 'assign' ? 'assign' : 'default';
|
||||
if (mode === 'default' && !this.canApplyFormDefaultValue(String(name), current, force)) continue;
|
||||
if (isEqual(current, normalized)) {
|
||||
if (mode === 'default') {
|
||||
this.lastDefaultValueByFieldName.set(String(name), normalized);
|
||||
} else {
|
||||
this.lastDefaultValueByFieldName.delete(String(name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof (form as any).setFieldValue === 'function') {
|
||||
(form as any).setFieldValue(name, normalized);
|
||||
} else {
|
||||
(form as any).setFieldsValue?.({ [String(name)]: normalized });
|
||||
}
|
||||
if (mode === 'default') {
|
||||
this.lastDefaultValueByFieldName.set(String(name), normalized);
|
||||
} else {
|
||||
this.lastDefaultValueByFieldName.delete(String(name));
|
||||
}
|
||||
appliedValues[String(name)] = normalized;
|
||||
}
|
||||
|
||||
return appliedValues;
|
||||
}
|
||||
|
||||
private handleFilterFormValuesChange(changedValues: any, allValues: any) {
|
||||
const refreshSeq = ++this.defaultValuesRefreshSeq;
|
||||
void (async () => {
|
||||
const appliedValues = await this.applyFormDefaultValues({ refreshSeq });
|
||||
if (refreshSeq !== this.defaultValuesRefreshSeq) return;
|
||||
|
||||
const finalChangedValues = { ...(changedValues || {}), ...(appliedValues || {}) };
|
||||
const finalAllValues = this.form?.getFieldsValue?.() || allValues;
|
||||
const payload = { changedValues: finalChangedValues, allValues: finalAllValues };
|
||||
this.dispatchEvent('formValuesChange', payload, { debounce: true });
|
||||
this.emitter.emit('formValuesChange', payload);
|
||||
})().catch((error) => {
|
||||
console.error('Failed to refresh filter form default values:', error);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTargetBlockRemoved(targetUid: string) {
|
||||
@@ -263,6 +586,7 @@ export class FilterFormBlockModel extends FilterBlockModel<{
|
||||
onFinish={() => {
|
||||
this.context.refreshTargets();
|
||||
}}
|
||||
onValuesChange={(changedValues, allValues) => this.handleFilterFormValuesChange(changedValues, allValues)}
|
||||
layoutProps={{ colon, labelAlign, labelWidth, labelWrap, layout }}
|
||||
>
|
||||
<FlowModelRenderer model={this.subModels.grid} showFlowSettings={false} />
|
||||
|
||||
+337
@@ -7,10 +7,97 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { FlowEngine } from '@nocobase/flow-engine';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { filterFormDefaultValues } from '../../../../actions/filterFormDefaultValues';
|
||||
import { FilterFormBlockModel } from '../FilterFormBlockModel';
|
||||
|
||||
function resolveTemplateValue(raw: any, values: Record<string, any>): any {
|
||||
if (typeof raw === 'string') {
|
||||
const matched = raw.match(/^\{\{\s*ctx\.formValues\.([^}]+?)\s*\}\}$/);
|
||||
return matched ? values[matched[1]] : raw;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((item) => resolveTemplateValue(item, values));
|
||||
}
|
||||
if (raw && typeof raw === 'object') {
|
||||
return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, resolveTemplateValue(value, values)]));
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function createFilterFormDefaultValuesModel(rules: any[], initialValues: Record<string, any> = {}) {
|
||||
const values = { ...initialValues };
|
||||
const createItem = (fieldPath: string, uid: string) => ({
|
||||
uid,
|
||||
fieldPath,
|
||||
props: { name: `${fieldPath}_${uid}` },
|
||||
getProps() {
|
||||
return this.props;
|
||||
},
|
||||
getStepParams(flowKey: string, stepKey: string) {
|
||||
if (flowKey === 'fieldSettings' && stepKey === 'init') {
|
||||
return { fieldPath };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
subModels: {
|
||||
field: {},
|
||||
},
|
||||
});
|
||||
const model = {
|
||||
defaultValuesRefreshSeq: 0,
|
||||
lastDefaultValueByFieldName: new Map<string, any>(),
|
||||
form: {
|
||||
getFieldsValue: () => ({ ...values }),
|
||||
getFieldValue: (name: string) => values[name],
|
||||
setFieldValue: (name: string, value: any) => {
|
||||
values[name] = value;
|
||||
},
|
||||
setFieldsValue: (next: Record<string, any>) => {
|
||||
Object.assign(values, next);
|
||||
},
|
||||
},
|
||||
context: {
|
||||
resolveJsonTemplate: vi.fn((raw) => resolveTemplateValue(raw, values)),
|
||||
app: {
|
||||
jsonLogic: {
|
||||
apply: vi.fn((logic: Record<string, any[]>) => {
|
||||
const [[operator, args]] = Object.entries(logic);
|
||||
if (operator === '$eq') return args[0] === args[1];
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
subModels: {
|
||||
grid: {
|
||||
subModels: {
|
||||
items: [createItem('nickname', 'nick'), createItem('username', 'user')],
|
||||
},
|
||||
},
|
||||
},
|
||||
getStepParams: vi.fn((flowKey: string, stepKey: string) => {
|
||||
if (flowKey === 'formFilterBlockModelSettings' && stepKey === 'defaultValues') {
|
||||
return { value: rules };
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
canApplyFormDefaultValue: (FilterFormBlockModel.prototype as any).canApplyFormDefaultValue,
|
||||
matchDefaultValueCondition: (FilterFormBlockModel.prototype as any).matchDefaultValueCondition,
|
||||
applyFormDefaultValues: FilterFormBlockModel.prototype.applyFormDefaultValues,
|
||||
handleFilterFormValuesChange: (FilterFormBlockModel.prototype as any).handleFilterFormValuesChange,
|
||||
dispatchEvent: vi.fn(),
|
||||
emitter: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return { model, values };
|
||||
}
|
||||
|
||||
describe('filter-form defaultValues wiring', () => {
|
||||
it('loads action and model modules', () => {
|
||||
expect(filterFormDefaultValues).toBeTruthy();
|
||||
@@ -48,4 +135,254 @@ describe('filter-form defaultValues wiring', () => {
|
||||
expect(model.initialDefaultsPromise).toBeUndefined();
|
||||
expect(model.applyFormDefaultValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes current form values in the filter form variable meta tree', async () => {
|
||||
const engine = new FlowEngine();
|
||||
|
||||
const dataSource = engine.context.dataSourceManager.getDataSource('main');
|
||||
dataSource.addCollection({
|
||||
name: 'users',
|
||||
filterTargetKey: ['id', 'tenantId'],
|
||||
fields: [
|
||||
{ name: 'id', type: 'integer', interface: 'number' },
|
||||
{ name: 'tenantId', type: 'string', interface: 'text' },
|
||||
{ name: 'name', type: 'string', interface: 'text' },
|
||||
],
|
||||
});
|
||||
dataSource.addCollection({
|
||||
name: 'departments',
|
||||
filterTargetKey: 'id',
|
||||
fields: [
|
||||
{ name: 'id', type: 'integer', interface: 'number' },
|
||||
{ name: 'name', type: 'string', interface: 'text' },
|
||||
{ name: 'owner', type: 'belongsTo', target: 'users', interface: 'm2o' },
|
||||
],
|
||||
});
|
||||
dataSource.addCollection({
|
||||
name: 'tasks',
|
||||
filterTargetKey: 'id',
|
||||
fields: [
|
||||
{ name: 'id', type: 'integer', interface: 'number' },
|
||||
{ name: 'title', type: 'string', interface: 'text' },
|
||||
{ name: 'department', type: 'belongsTo', target: 'departments', interface: 'm2o' },
|
||||
],
|
||||
});
|
||||
|
||||
engine.registerModels({ FilterFormBlockModel });
|
||||
const model = engine.createModel<FilterFormBlockModel>({
|
||||
use: 'FilterFormBlockModel',
|
||||
uid: 'filter-form-current-form',
|
||||
subModels: {
|
||||
grid: {
|
||||
subModels: {
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
function HookCaller() {
|
||||
model.useHooksBeforeRender();
|
||||
return null;
|
||||
}
|
||||
|
||||
render(React.createElement(HookCaller));
|
||||
|
||||
const store = {
|
||||
title: 'bug',
|
||||
'department_department-filter': 1,
|
||||
'department.owner_owner-filter': { id: 7, tenantId: 'tenant-a' },
|
||||
};
|
||||
const fakeForm = {
|
||||
getFieldsValue: () => ({ ...store }),
|
||||
};
|
||||
model.context.defineProperty('form', { value: fakeForm });
|
||||
model.subModels.grid.subModels.items = [
|
||||
{
|
||||
uid: 'department-filter',
|
||||
fieldPath: 'department',
|
||||
props: { name: 'department_department-filter' },
|
||||
subModels: {
|
||||
field: {
|
||||
context: {
|
||||
collectionField: dataSource.getCollection('tasks').getField('department'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
uid: 'owner-filter',
|
||||
fieldPath: 'department.owner',
|
||||
props: { name: 'department.owner_owner-filter' },
|
||||
subModels: {
|
||||
field: {
|
||||
context: {
|
||||
collectionField: dataSource.getCollection('departments').getField('owner'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect((model.context as any).formValues).toMatchObject({
|
||||
...store,
|
||||
department: {
|
||||
'owner_owner-filter': { id: 7, tenantId: 'tenant-a' },
|
||||
},
|
||||
});
|
||||
|
||||
const options = (model.context as any).getPropertyOptions('formValues');
|
||||
const meta = await options.meta();
|
||||
const properties = await meta.properties();
|
||||
const metaTree = await (model.context as any).getPropertyMetaTree();
|
||||
|
||||
expect(options.resolveOnServer('department_department-filter')).toBe(false);
|
||||
expect(options.resolveOnServer('department_department-filter.name')).toBe(true);
|
||||
expect(options.resolveOnServer('department_department-filter[0].name')).toBe(true);
|
||||
expect(options.resolveOnServer('department.owner_owner-filter.name')).toBe(true);
|
||||
expect(options.serverOnlyWhenContextParams).toBe(true);
|
||||
expect(meta.title).toBe('Current form');
|
||||
expect(properties.department.properties['owner_owner-filter'].title).toBe('owner');
|
||||
expect(metaTree).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'formValues',
|
||||
title: 'Current form',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(await meta.buildVariablesParams(model.context)).toMatchObject({
|
||||
'department_department-filter': { collection: 'departments', dataSourceKey: 'main', filterByTk: 1 },
|
||||
department: {
|
||||
'owner_owner-filter': {
|
||||
collection: 'users',
|
||||
dataSourceKey: 'main',
|
||||
filterByTk: { id: 7, tenantId: 'tenant-a' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes default values that depend on current filter form values', async () => {
|
||||
const { model, values } = createFilterFormDefaultValuesModel(
|
||||
[
|
||||
{
|
||||
key: 'username-default',
|
||||
enable: true,
|
||||
targetPath: 'username',
|
||||
mode: 'default',
|
||||
value: '{{ ctx.formValues.nickname_nick }}',
|
||||
},
|
||||
],
|
||||
{ nickname_nick: 'Alice' },
|
||||
);
|
||||
|
||||
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
|
||||
expect(values.username_user).toBe('Alice');
|
||||
|
||||
values.nickname_nick = 'Bob';
|
||||
model.defaultValuesRefreshSeq += 1;
|
||||
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, {
|
||||
refreshSeq: model.defaultValuesRefreshSeq,
|
||||
});
|
||||
expect(values.username_user).toBe('Bob');
|
||||
|
||||
values.username_user = 'Manual';
|
||||
values.nickname_nick = 'Carol';
|
||||
model.defaultValuesRefreshSeq += 1;
|
||||
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any, {
|
||||
refreshSeq: model.defaultValuesRefreshSeq,
|
||||
});
|
||||
expect(values.username_user).toBe('Manual');
|
||||
});
|
||||
|
||||
it('applies fixed values even when the target filter field already has a value', async () => {
|
||||
const { model, values } = createFilterFormDefaultValuesModel(
|
||||
[
|
||||
{
|
||||
key: 'username-fixed',
|
||||
enable: true,
|
||||
targetPath: 'username',
|
||||
mode: 'assign',
|
||||
value: '{{ ctx.formValues.nickname_nick }}',
|
||||
},
|
||||
],
|
||||
{ nickname_nick: 'Bob', username_user: 'Manual' },
|
||||
);
|
||||
|
||||
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
|
||||
|
||||
expect(values.username_user).toBe('Bob');
|
||||
});
|
||||
|
||||
it('skips filter form field values when the rule condition does not match', async () => {
|
||||
const { model, values } = createFilterFormDefaultValuesModel(
|
||||
[
|
||||
{
|
||||
key: 'username-condition',
|
||||
enable: true,
|
||||
targetPath: 'username',
|
||||
mode: 'assign',
|
||||
condition: {
|
||||
logic: '$and',
|
||||
items: [{ path: '{{ ctx.formValues.nickname_nick }}', operator: '$eq', value: 'allow' }],
|
||||
},
|
||||
value: 'Matched',
|
||||
},
|
||||
],
|
||||
{ nickname_nick: 'deny' },
|
||||
);
|
||||
|
||||
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
|
||||
expect(values.username_user).toBeUndefined();
|
||||
|
||||
values.nickname_nick = 'allow';
|
||||
await FilterFormBlockModel.prototype.applyFormDefaultValues.call(model as any);
|
||||
expect(values.username_user).toBe('Matched');
|
||||
});
|
||||
|
||||
it('emits formValuesChange with final values after applying dependent field values', async () => {
|
||||
const { model, values } = createFilterFormDefaultValuesModel(
|
||||
[
|
||||
{
|
||||
key: 'username-fixed',
|
||||
enable: true,
|
||||
targetPath: 'username',
|
||||
mode: 'assign',
|
||||
value: '{{ ctx.formValues.nickname_nick }}',
|
||||
},
|
||||
],
|
||||
{ nickname_nick: 'Bob' },
|
||||
);
|
||||
|
||||
(model as any).handleFilterFormValuesChange({ nickname_nick: 'Bob' }, { nickname_nick: 'Bob' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(values.username_user).toBe('Bob');
|
||||
expect(model.dispatchEvent).toHaveBeenCalledWith(
|
||||
'formValuesChange',
|
||||
{
|
||||
changedValues: {
|
||||
nickname_nick: 'Bob',
|
||||
username_user: 'Bob',
|
||||
},
|
||||
allValues: {
|
||||
nickname_nick: 'Bob',
|
||||
username_user: 'Bob',
|
||||
},
|
||||
},
|
||||
{ debounce: true },
|
||||
);
|
||||
});
|
||||
expect(model.emitter.emit).toHaveBeenCalledWith('formValuesChange', {
|
||||
changedValues: {
|
||||
nickname_nick: 'Bob',
|
||||
username_user: 'Bob',
|
||||
},
|
||||
allValues: {
|
||||
nickname_nick: 'Bob',
|
||||
username_user: 'Bob',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user