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:
Zeke Zhang
2026-05-19 07:45:35 +08:00
committed by GitHub
parent 3f9513ebd2
commit e1dcf19352
3 changed files with 667 additions and 7 deletions
@@ -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} />
@@ -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',
},
});
});
});