From e1dcf1935274b7f65e2a6c363dbfa2340a5808fd Mon Sep 17 00:00:00 2001 From: Zeke Zhang <958414905@qq.com> Date: Tue, 19 May 2026 07:45:35 +0800 Subject: [PATCH] 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 --- .../flow/actions/filterFormDefaultValues.tsx | 3 +- .../filter-form/FilterFormBlockModel.tsx | 334 ++++++++++++++++- .../__tests__/defaultValues.wiring.test.ts | 337 ++++++++++++++++++ 3 files changed, 667 insertions(+), 7 deletions(-) diff --git a/packages/core/client/src/flow/actions/filterFormDefaultValues.tsx b/packages/core/client/src/flow/actions/filterFormDefaultValues.tsx index 9e968bab70f..b34a34894d4 100644 --- a/packages/core/client/src/flow/actions/filterFormDefaultValues.tsx +++ b/packages/core/client/src/flow/actions/filterFormDefaultValues.tsx @@ -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 /> ); }, diff --git a/packages/core/client/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx b/packages/core/client/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx index 10390e257a7..7db6eeb7346 100644 --- a/packages/core/client/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +++ b/packages/core/client/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx @@ -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 = {}; + 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 = {}; + 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, 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, 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; + }); +} + +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; private initialRefreshHandledTargetIds = new Set(); + private lastDefaultValueByFieldName = new Map(); + 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 = {}; + 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 = {}; + + 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 = {}; 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 }} > diff --git a/packages/core/client/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts b/packages/core/client/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts index 4622ee55477..13ce43c9e80 100644 --- a/packages/core/client/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +++ b/packages/core/client/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts @@ -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): 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 = {}) { + 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(), + form: { + getFieldsValue: () => ({ ...values }), + getFieldValue: (name: string) => values[name], + setFieldValue: (name: string, value: any) => { + values[name] = value; + }, + setFieldsValue: (next: Record) => { + Object.assign(values, next); + }, + }, + context: { + resolveJsonTemplate: vi.fn((raw) => resolveTemplateValue(raw, values)), + app: { + jsonLogic: { + apply: vi.fn((logic: Record) => { + 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({ + 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', + }, + }); + }); });