chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,214 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, it } from 'vitest';
import { FieldModel } from '@/core/field-model';
import { FieldArrayModel } from '@/core/field-array-model';
import { createForm } from '@/core/create-form';
describe('createForm', () => {
it('should create form with auto initialization by default', () => {
const { form, control } = createForm();
expect(form).toBeDefined();
expect(control).toBeDefined();
expect(control._formModel.initialized).toBe(true);
});
it('should disableAutoInit work', async () => {
const { control } = createForm({ disableAutoInit: true });
expect(control._formModel.initialized).toBe(false);
control.init();
expect(control._formModel.initialized).toBe(true);
});
it('should create form with initial values', () => {
const initialValues = {
username: 'John',
email: 'john@example.com',
age: 30,
};
const { form } = createForm({ initialValues });
expect(form.initialValues).toEqual(initialValues);
expect(form.values).toEqual(initialValues);
});
it('should create form with validation', async () => {
const { form } = createForm({
initialValues: { username: '' },
});
// Validation should be callable
const errors = await form.validate();
// Without explicit validators, errors should be empty or undefined
expect(errors === undefined || Object.keys(errors).length === 0).toBe(true);
});
it('should create form with empty options', () => {
const { form, control } = createForm({});
expect(form).toBeDefined();
expect(control).toBeDefined();
expect(control._formModel.initialized).toBe(true);
});
it('should create form without options', () => {
const { form, control } = createForm();
expect(form).toBeDefined();
expect(control).toBeDefined();
expect(control._formModel.initialized).toBe(true);
});
describe('control.getField', () => {
it('should get field by name', () => {
const { form, control } = createForm({
initialValues: {
username: 'John',
},
});
// Create field first
control._formModel.createField('username');
const field = control.getField('username');
expect(field).toBeDefined();
expect(field!.name).toBe('username');
expect(field!.value).toBe('John');
});
it('should return undefined for non-existent field', () => {
const { control } = createForm();
const field = control.getField('nonexistent');
expect(field).toBeUndefined();
});
it('should get FieldArray when field is array', () => {
const { control } = createForm({
initialValues: {
users: [{ name: 'Alice' }, { name: 'Bob' }],
},
});
// Create field array
control._formModel.createFieldArray('users');
const fieldArray = control.getField('users');
expect(fieldArray).toBeDefined();
expect(fieldArray!.name).toBe('users');
expect(Array.isArray(fieldArray!.value)).toBe(true);
});
it('should return Field for regular field', () => {
const { control } = createForm({
initialValues: {
username: 'John',
},
});
control._formModel.createField('username');
const field = control.getField('username');
expect(field).toBeDefined();
expect(field!.name).toBe('username');
expect((field as any).onChange).toBeDefined();
});
it('should return FieldArray for array field with array methods', () => {
const { control } = createForm({
initialValues: {
users: [{ name: 'Alice' }],
},
});
control._formModel.createFieldArray('users');
const fieldArrayModel = control._formModel.getField('users');
expect(fieldArrayModel).toBeInstanceOf(FieldArrayModel);
const fieldArray = control.getField('users');
expect(fieldArray).toBeDefined();
expect((fieldArray as any).append).toBeDefined();
expect((fieldArray as any).remove).toBeDefined();
expect((fieldArray as any).swap).toBeDefined();
expect((fieldArray as any).move).toBeDefined();
});
it('should handle nested field names', () => {
const { control } = createForm({
initialValues: {
user: {
profile: {
name: 'Alice',
},
},
},
});
control._formModel.createField('user.profile.name');
const field = control.getField('user.profile.name');
expect(field).toBeDefined();
expect(field!.name).toBe('user.profile.name');
expect(field!.value).toBe('Alice');
});
it('should handle array index in field names', () => {
const { control } = createForm({
initialValues: {
users: [{ name: 'Alice' }, { name: 'Bob' }],
},
});
control._formModel.createField('users.0.name');
const field = control.getField('users.0.name');
expect(field).toBeDefined();
expect(field!.name).toBe('users.0.name');
expect(field!.value).toBe('Alice');
});
});
describe('control.init', () => {
it('should initialize form with new options', () => {
const { control } = createForm({ disableAutoInit: true });
expect(control._formModel.initialized).toBe(false);
control.init();
expect(control._formModel.initialized).toBe(true);
});
it('should reinitialize form', () => {
const { control } = createForm({
initialValues: { username: 'John' },
});
expect(control._formModel.initialized).toBe(true);
expect(control._formModel.initialValues).toEqual({ username: 'John' });
control._formModel.dispose();
control.init();
expect(control._formModel.initialized).toBe(true);
});
});
it('should expose _formModel on control', () => {
const { control } = createForm();
expect(control._formModel).toBeDefined();
expect(control._formModel.init).toBeDefined();
expect(control._formModel.createField).toBeDefined();
});
});
@@ -0,0 +1,931 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Errors, ValidateTrigger, Warnings } from '@/types';
import { FormModel } from '@/core/form-model';
import { type FieldArrayModel } from '@/core/field-array-model';
import { FeedbackLevel } from '../src/types';
describe('FormArrayModel', () => {
let formModel = new FormModel();
describe('children', () => {
let arrayField: FieldArrayModel;
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
// 创建数组
formModel.createFieldArray('arr');
const field = formModel.getField<FieldArrayModel>('arr');
field!.append('a');
field!.append('b');
field!.append('c');
arrayField = field!;
});
it('can get children', () => {
expect(arrayField.children.length).toBe(3);
});
});
describe('append & delete', () => {
let arrayField: FieldArrayModel;
let arrEffect = vi.fn();
let aEffect = vi.fn();
let bEffect = vi.fn();
let cEffect = vi.fn();
let appendEffect = vi.fn();
let deleteEffect = vi.fn();
let aValidate = vi.fn();
let bValidate = vi.fn();
let cValidate = vi.fn();
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
arrEffect = vi.fn();
aEffect = vi.fn();
bEffect = vi.fn();
cEffect = vi.fn();
appendEffect = vi.fn();
deleteEffect = vi.fn();
aValidate = vi.fn();
bValidate = vi.fn();
cValidate = vi.fn();
formModel.init({
validateTrigger: ValidateTrigger.onChange,
validate: {
['arr.0']: aValidate,
['arr.1']: bValidate,
['arr.2']: cValidate,
},
});
// 创建其他field, 用于测试其他元素不会被影响
formModel.createField('other');
// 创建数组
formModel.createFieldArray('arr');
const field = formModel.getField<FieldArrayModel>('arr');
const a = field!.append('a');
const b = field!.append('b');
const c = field!.append('c');
arrayField = field!;
arrayField.onValueChange(arrEffect);
arrayField.onAppend(appendEffect);
arrayField.onDelete(deleteEffect);
a.onValueChange(aEffect);
b.onValueChange(bEffect);
c.onValueChange(cEffect);
});
it('append', async () => {
vi.spyOn(arrayField, 'validate');
arrayField.append('d');
expect(arrayField.children.length).toBe(4);
expect(formModel.getField('other')).toBeDefined();
expect(arrEffect).toHaveBeenCalledTimes(1);
expect(appendEffect).toHaveBeenCalledTimes(1);
expect(arrayField.validate).toHaveBeenCalledTimes(1);
});
it('should fire OnFormValueChange event for arr when append', () => {
vi.spyOn(formModel.onFormValuesInitEmitter, 'fire');
vi.spyOn(formModel.onFormValuesChangeEmitter, 'fire');
arrayField.append('d');
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledWith({
values: {
arr: ['a', 'b', 'c', 'd'],
},
prevValues: {
arr: ['a', 'b', 'c'],
},
name: 'arr',
options: {
action: 'array-append',
indexes: [3],
},
});
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledWith({
values: {
arr: ['a', 'b', 'c', 'd'],
},
prevValues: {
arr: ['a', 'b', 'c'],
},
name: 'arr.3',
});
});
it('delete first element', () => {
vi.spyOn(formModel.onFormValuesChangeEmitter, 'fire');
vi.spyOn(arrayField, 'validate');
vi.spyOn(arrayField.onValueChangeEmitter, 'fire');
arrayField.delete(0);
// assert value
expect(arrayField.children.length).toBe(2);
expect(arrayField.children[0].value).toBe('b');
expect(arrayField.children[1].value).toBe('c');
expect(formModel.getField('other')).toBeDefined();
// assert change events
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
expect(aEffect).toHaveBeenCalledTimes(1);
expect(bEffect).toHaveBeenCalledTimes(1);
expect(cEffect).toHaveBeenCalledTimes(1);
expect(deleteEffect).toHaveBeenCalledTimes(1);
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledWith({
values: {
arr: ['b', 'c'],
},
prevValues: {
arr: ['a', 'b', 'c'],
},
name: 'arr',
options: {
action: 'array-splice',
indexes: [0],
},
});
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledTimes(1);
// assert validate trigger
expect(arrayField.validate).toHaveBeenCalledTimes(1);
expect(aValidate).not.toHaveBeenCalled();
expect(bValidate).not.toHaveBeenCalled();
expect(cValidate).not.toHaveBeenCalled();
});
it('delete middle element', () => {
vi.spyOn(arrayField, 'validate');
vi.spyOn(arrayField.onValueChangeEmitter, 'fire');
arrayField.delete(1);
// assert values
expect(arrayField.children.length).toBe(2);
expect(arrayField.children[0].value).toBe('a');
expect(arrayField.children[1].value).toBe('c');
expect(formModel.getField('other')).toBeDefined();
// assert change events
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
expect(aEffect).not.toHaveBeenCalled();
expect(bEffect).toHaveBeenCalledTimes(1);
expect(cEffect).toHaveBeenCalledTimes(1);
// assert validate trigger
expect(bValidate).not.toHaveBeenCalled();
expect(cValidate).not.toHaveBeenCalled();
expect(arrayField.validate).toHaveBeenCalledTimes(1);
});
it('delete last element', () => {
arrayField.delete(2);
expect(arrayField.children.length).toBe(2);
expect(arrayField.children[0].value).toBe('a');
expect(arrayField.children[1].value).toBe('b');
expect(formModel.getField('other')).toBeDefined();
expect(arrEffect).toHaveBeenCalled();
expect(cEffect).toHaveBeenCalled();
});
it('delete element which has nested field', () => {
vi.spyOn(arrayField, 'validate');
const axField = formModel.createField('arr.0.x');
const bxField = formModel.createField('arr.1.x');
vi.spyOn(axField, 'validate');
vi.spyOn(bxField, 'validate');
formModel.setValueIn('arr.0', { x: 1 });
formModel.setValueIn('arr.1', { x: 2 });
expect(arrayField.value).toEqual([{ x: 1 }, { x: 2 }, 'c']);
arrayField.delete(0);
expect(arrayField.value).toEqual([{ x: 2 }, 'c']);
// assert change events
expect(aEffect).toHaveBeenCalledTimes(2); // setValueIn 触发一次, delete 触发一次
expect(bEffect).toHaveBeenCalledTimes(2); // setValueIn 触发一次, delete 触发一次
expect(cEffect).toHaveBeenCalledTimes(1);
// assert validate trigger
expect(aValidate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
expect(bValidate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
expect(cValidate).not.toHaveBeenCalled();
expect(axField.validate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
expect(bxField.validate).toHaveBeenCalledTimes(1); // setValueIn 触发一次, delete 不会触发
});
it('more elements delete', () => {
/**
* 数组为 [a,b,c,d]
* 删除 b
* 希望数组值为 [a,c,d]
* 希望formModel中的field也正确对应
*/
arrayField.append('d');
vi.spyOn(arrayField, 'validate');
arrayField.delete(1);
// assert values
expect(arrayField.children.length).toBe(3);
expect(arrayField.children[0].value).toBe('a');
expect(arrayField.children[1].value).toBe('c');
expect(arrayField.children[2].value).toBe('d');
expect(formModel.getField('arr.2')?.value).toBe('d');
expect(formModel.getField('other')).toBeDefined();
// assert value change events
expect(arrEffect).toHaveBeenCalled();
expect(bEffect).toHaveBeenCalled();
expect(cEffect).toHaveBeenCalled();
expect(arrayField.validate).toHaveBeenCalledTimes(1);
});
});
describe('_splice', () => {
let arrayField: FieldArrayModel;
let aEffect = vi.fn();
let bEffect = vi.fn();
let cEffect = vi.fn();
let dEffect = vi.fn();
let eEffect = vi.fn();
let aValidate = vi.fn();
let bValidate = vi.fn();
let cValidate = vi.fn();
let dValidate = vi.fn();
let eValidate = vi.fn();
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
aEffect = vi.fn();
bEffect = vi.fn();
cEffect = vi.fn();
dEffect = vi.fn();
eEffect = vi.fn();
aValidate = vi.fn();
bValidate = vi.fn();
cValidate = vi.fn();
dValidate = vi.fn();
eValidate = vi.fn();
formModel.createFieldArray('arr');
formModel.init({
validateTrigger: ValidateTrigger.onChange,
validate: {
['arr.0']: aValidate,
['arr.1']: bValidate,
['arr.2']: cValidate,
['arr.3']: dValidate,
['arr.4']: eValidate,
},
});
const field = formModel.getField<FieldArrayModel>('arr');
const aField = field!.append('a');
const bField = field!.append('b');
const cField = field!.append('c');
const dField = field!.append('d');
const eField = field!.append('e');
aField.onValueChange(aEffect);
bField.onValueChange(bEffect);
cField.onValueChange(cEffect);
dField.onValueChange(dEffect);
eField.onValueChange(eEffect);
arrayField = field!;
vi.spyOn(arrayField, 'validate');
vi.spyOn(arrayField.onValueChangeEmitter, 'fire');
});
it('should throw error when delete count exceeds array length', () => {
expect(() => {
arrayField._splice(0, 6);
}).toThrowError();
});
it('should throw error when delete in empty array', () => {
arrayField._splice(0, 5);
expect(() => {
arrayField._splice(0);
}).toThrowError();
});
it('splice first 2', () => {
arrayField._splice(0, 2);
// assert values
expect(arrayField.children.length).toBe(3);
expect(arrayField.children[0].value).toBe('c');
expect(arrayField.children[1].value).toBe('d');
expect(arrayField.children[2].value).toBe('e');
// assert value change events
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
expect(aEffect).toHaveBeenCalledTimes(1);
expect(bEffect).toHaveBeenCalledTimes(1);
expect(cEffect).toHaveBeenCalledTimes(1);
expect(dEffect).toHaveBeenCalledTimes(1);
expect(eEffect).toHaveBeenCalledTimes(1);
// assert validate trigger
expect(arrayField.validate).toHaveBeenCalledTimes(1);
expect(aValidate).not.toHaveBeenCalled();
expect(bValidate).not.toHaveBeenCalled();
expect(cValidate).not.toHaveBeenCalled();
expect(dValidate).not.toHaveBeenCalled();
expect(eValidate).not.toHaveBeenCalled();
});
it('splice last 2', () => {
arrayField._splice(3, 2);
// assert values
expect(arrayField.children.length).toBe(3);
expect(arrayField.children[0].value).toBe('a');
expect(arrayField.children[1].value).toBe('b');
expect(arrayField.children[2].value).toBe('c');
// assert value change events
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
expect(aEffect).not.toHaveBeenCalled();
expect(bEffect).not.toHaveBeenCalled();
expect(cEffect).not.toHaveBeenCalled();
expect(dEffect).toHaveBeenCalledTimes(1);
expect(eEffect).toHaveBeenCalledTimes(1);
// assert validate trigger
expect(arrayField.validate).toHaveBeenCalledTimes(1);
expect(aValidate).not.toHaveBeenCalled();
expect(bValidate).not.toHaveBeenCalled();
expect(cValidate).not.toHaveBeenCalled();
expect(dValidate).not.toHaveBeenCalled();
expect(eValidate).not.toHaveBeenCalled();
});
it('splice middle elements', () => {
arrayField._splice(1, 2);
// assert values
expect(arrayField.children.length).toBe(3);
expect(arrayField.children[0].value).toBe('a');
expect(arrayField.children[1].value).toBe('d');
expect(arrayField.children[2].value).toBe('e');
// assert value change events
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
expect(aEffect).not.toHaveBeenCalled();
expect(bEffect).toHaveBeenCalledTimes(1);
expect(cEffect).toHaveBeenCalledTimes(1);
expect(dEffect).toHaveBeenCalledTimes(1);
expect(eEffect).toHaveBeenCalledTimes(1);
// assert validate trigger
expect(arrayField.validate).toHaveBeenCalledTimes(1);
expect(aValidate).not.toHaveBeenCalled();
expect(bValidate).not.toHaveBeenCalled();
expect(cValidate).not.toHaveBeenCalled();
expect(dValidate).not.toHaveBeenCalled();
expect(eValidate).not.toHaveBeenCalled();
});
it('splice all elements', () => {
arrayField._splice(0, 5);
expect(arrayField.children.length).toBe(0);
expect(arrayField.value).toEqual([]);
// assert value change events
expect(arrayField.onValueChangeEmitter.fire).toHaveBeenCalledTimes(1);
expect(aEffect).toHaveBeenCalledTimes(1);
expect(bEffect).toHaveBeenCalledTimes(1);
expect(cEffect).toHaveBeenCalledTimes(1);
expect(dEffect).toHaveBeenCalledTimes(1);
expect(eEffect).toHaveBeenCalledTimes(1);
// assert validate trigger
expect(arrayField.validate).toHaveBeenCalledTimes(1);
expect(aValidate).not.toHaveBeenCalled();
expect(bValidate).not.toHaveBeenCalled();
expect(cValidate).not.toHaveBeenCalled();
expect(dValidate).not.toHaveBeenCalled();
expect(eValidate).not.toHaveBeenCalled();
});
});
describe('State check when _splice', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('should keep state of rest fields after delete a prev field', () => {
const arrayField = formModel.createFieldArray('arr');
formModel.init({
validateTrigger: ValidateTrigger.onChange,
});
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
// 设置第1项的state
const aFieldModel = formModel.getField('arr.1');
aFieldModel!.state.errors = {
'arr.1': [{ name: 'arr.1', message: 'error' }],
} as unknown as Errors;
aFieldModel!.state.warnings = {
'arr.1': [{ name: 'arr.1', message: 'warning' }],
} as unknown as Warnings;
// 删除第0项
arrayField._splice(0);
// 原第一项变为第0项且他的state 被保留了, 且errors 中的路径标识也更新了
expect(formModel.getField('arr.0')!.state.errors).toEqual({
'arr.0': [{ name: 'arr.0', message: 'error' }],
});
expect(formModel.getField('arr.0')!.state.warnings).toEqual({
'arr.0': [{ name: 'arr.0', message: 'warning' }],
});
expect(formModel.getField('arr.2')).toBeUndefined();
});
it('should keep state of rest fields after delete a prev field, when nested field', () => {
const arrayField = formModel.createFieldArray('arr');
formModel.init({
validateTrigger: ValidateTrigger.onChange,
initialValues: {
arr: [
{ x: 1, y: 2 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
],
},
});
formModel.createField('arr.0');
formModel.createField('arr.0.x');
formModel.createField('arr.0.y');
formModel.createField('arr.1');
formModel.createField('arr.1.x');
formModel.createField('arr.1.y');
formModel.createField('arr.2');
formModel.createField('arr.2.x');
formModel.createField('arr.2.y');
// 设置第1项的state
const aFieldModel = formModel.getField('arr.1.x');
aFieldModel!.state.errors = {
'arr.1.x': [{ name: 'arr.1.x', message: 'error' }],
} as unknown as Errors;
// 删除第0项
arrayField._splice(0);
// 原第一项变为第0项且他的state errors 被保留了, 且errors 中的路径标识也更新了
expect(formModel.getField('arr.0')!.state.errors).toEqual({
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
});
expect(formModel.getField('arr.0.x')!.state.errors).toEqual({
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
});
expect(formModel.getField('arr.2')).toBeUndefined();
expect(formModel.getField('arr.2.x')).toBeUndefined();
expect(formModel.getField('arr.2.y')).toBeUndefined();
});
it('should align errors and warnings state with existing field in fieldMap ', () => {
const arrayField = formModel.createFieldArray('arr');
formModel.init({
validateTrigger: ValidateTrigger.onChange,
initialValues: {
arr: [
{ x: 1, y: 2 },
{ x: 0, y: 0 },
{ x: 0, y: 0 },
],
},
});
const field0 = formModel.createField('arr.0');
const field0x = formModel.createField('arr.0.x');
const field0y = formModel.createField('arr.0.y');
const field1 = formModel.createField('arr.1');
const field1x = formModel.createField('arr.1.x');
const field1y = formModel.createField('arr.1.y');
const field2 = formModel.createField('arr.2');
const field2x = formModel.createField('arr.2.x');
const field2y = formModel.createField('arr.2.y');
field0x.state.errors = {
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
} as unknown as Errors;
field0x.bubbleState();
field1x.state.errors = {
'arr.1.x': [{ name: 'arr.1.x', message: 'error' }],
} as unknown as Errors;
field1x.bubbleState();
field2x.state.errors = {
'arr.2.x': [{ name: 'arr.2.x', message: 'error' }],
} as unknown as Errors;
// 删除第0项
arrayField._splice(0);
expect(formModel.state.errors['arr.0.x']).toEqual([{ name: 'arr.0.x', message: 'error' }]);
expect(formModel.state.errors['arr.1.x']).toEqual([{ name: 'arr.1.x', message: 'error' }]);
expect(formModel.state.errors['arr.2.x']).toBeUndefined();
expect(field0.state.errors['arr.0.x']).toEqual([{ name: 'arr.0.x', message: 'error' }]);
expect(field1.state.errors['arr.1.x']).toEqual([{ name: 'arr.1.x', message: 'error' }]);
expect(arrayField.state.errors['arr.0.x']).toEqual([{ name: 'arr.0.x', message: 'error' }]);
expect(arrayField.state.errors['arr.1.x']).toEqual([{ name: 'arr.1.x', message: 'error' }]);
expect(arrayField.state.errors['arr.2.x']).toBeUndefined();
});
it('should not keep previous error state when delete first elem in array then add back ', () => {
const arrayField = formModel.createFieldArray('arr');
formModel.init({
validateTrigger: ValidateTrigger.onChange,
initialValues: {
arr: [{ x: 1, y: 2 }],
},
});
const field0 = formModel.createField('arr.0');
const field0x = formModel.createField('arr.0.x');
const field0y = formModel.createField('arr.0.y');
field0x.state.errors = {
'arr.0.x': [{ name: 'arr.0.x', message: 'error' }],
} as unknown as Errors;
field0x.bubbleState();
// 删除第0项
arrayField._splice(0);
expect(formModel.state.errors['arr.0.x']).toBeUndefined();
expect(arrayField.state.errors['arr.0.x']).toBeUndefined();
expect(formModel._fieldMap.get('arr.0')).toBeUndefined();
arrayField.append({ x: 1, y: 2 });
formModel.createField('arr.0.x');
expect(formModel._fieldMap.get('arr.0')).toBeDefined();
expect(formModel.state.errors['arr.0.x']).toBeUndefined();
expect(formModel._fieldMap.get('arr.0.x').state.errors).toBeUndefined();
});
});
describe('swap', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('can swap from 0 to middle index', () => {
const arrayField = formModel.createFieldArray('arr');
const a = arrayField!.append('a');
const b = arrayField!.append('b');
const c = arrayField!.append('c');
formModel.init({});
a.state.errors = {
'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }],
};
b.state.errors = {
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
};
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.swap(0, 1);
expect(formModel.values).toEqual({ arr: ['b', 'a', 'c'] });
expect(formModel.getField('arr.0').state.errors).toEqual({
'arr.0': [{ name: 'arr.0', message: 'err1', level: FeedbackLevel.Error }],
});
expect(formModel.getField('arr.1').state.errors).toEqual({
'arr.1': [{ name: 'arr.1', message: 'err0', level: FeedbackLevel.Error }],
});
});
it('can chained swap', () => {
const arrayField = formModel.createFieldArray('x.arr');
const a = arrayField!.append('a');
const b = arrayField!.append('b');
arrayField!.append('c');
formModel.init({});
a.state.errors = {
'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }],
};
b.state.errors = {
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
};
expect(a.name).toBe('x.arr.0');
expect(b.name).toBe('x.arr.1');
expect(formModel.values.x).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.swap(1, 0);
expect(a.name).toBe('x.arr.1');
expect(b.name).toBe('x.arr.0');
expect(formModel.values.x).toEqual({ arr: ['b', 'a', 'c'] });
arrayField.swap(1, 0);
expect(a.name).toBe('x.arr.0');
expect(formModel.fieldMap.get('x.arr.0').name).toBe('x.arr.0');
expect(b.name).toBe('x.arr.1');
expect(formModel.fieldMap.get('x.arr.1').name).toBe('x.arr.1');
expect(formModel.values.x).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.swap(1, 0);
expect(a.name).toBe('x.arr.1');
expect(formModel.fieldMap.get('x.arr.1').name).toBe('x.arr.1');
expect(b.name).toBe('x.arr.0');
expect(formModel.fieldMap.get('x.arr.0').name).toBe('x.arr.0');
expect(formModel.values.x).toEqual({ arr: ['b', 'a', 'c'] });
});
it('can swap from 0 to last index', () => {
const arrayField = formModel.createFieldArray('arr');
const a = arrayField!.append('a');
const b = arrayField!.append('b');
const c = arrayField!.append('c');
formModel.init({});
a.state.errors = {
'arr.0': [{ name: 'arr.0', message: 'err0', level: FeedbackLevel.Error }],
};
c.state.errors = {
'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }],
};
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.swap(0, 2);
expect(formModel.values).toEqual({ arr: ['c', 'b', 'a'] });
expect(formModel.getField('arr.0').state.errors).toEqual({
'arr.0': [{ name: 'arr.0', message: 'err2', level: FeedbackLevel.Error }],
});
expect(formModel.getField('arr.2').state.errors).toEqual({
'arr.2': [{ name: 'arr.2', message: 'err0', level: FeedbackLevel.Error }],
});
});
it('can swap from middle index to last index', () => {
const arrayField = formModel.createFieldArray('arr');
const a = arrayField!.append('a');
const b = arrayField!.append('b');
const c = arrayField!.append('c');
formModel.init({});
b.state.errors = {
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
};
c.state.errors = {
'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }],
};
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.swap(1, 2);
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b'] });
expect(formModel.getField('arr.1').state.errors).toEqual({
'arr.1': [{ name: 'arr.1', message: 'err2', level: FeedbackLevel.Error }],
});
expect(formModel.getField('arr.2').state.errors).toEqual({
'arr.2': [{ name: 'arr.2', message: 'err1', level: FeedbackLevel.Error }],
});
});
it('can swap from middle index to another middle index', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
const b = arrayField!.append('b');
const c = arrayField!.append('c');
arrayField!.append('d');
formModel.init({});
b.state.errors = {
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
};
c.state.errors = {
'arr.2': [{ name: 'arr.2', message: 'err2', level: FeedbackLevel.Error }],
};
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd'] });
arrayField.swap(1, 2);
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b', 'd'] });
expect(formModel.getField('arr.1').state.errors).toEqual({
'arr.1': [{ name: 'arr.1', message: 'err2', level: FeedbackLevel.Error }],
});
expect(formModel.getField('arr.2').state.errors).toEqual({
'arr.2': [{ name: 'arr.2', message: 'err1', level: FeedbackLevel.Error }],
});
});
it('can swap for nested array', () => {
const arrayField = formModel.createFieldArray('arr');
const a = arrayField!.append({ x: 'x0', y: 'y0' });
const b = arrayField!.append({ x: 'x1', y: 'y1' });
const ax = formModel.createField('arr.0.x');
const ay = formModel.createField('arr.0.y');
const bx = formModel.createField('arr.1.x');
const by = formModel.createField('arr.1.y');
formModel.init({});
ax.state.errors = {
'arr.0.x': [{ name: 'arr.0.x', message: 'err0x', level: FeedbackLevel.Error }],
};
bx.state.errors = {
'arr.1.x': [{ name: 'arr.1.x', message: 'err1x', level: FeedbackLevel.Error }],
};
expect(formModel.values).toEqual({
arr: [
{ x: 'x0', y: 'y0' },
{ x: 'x1', y: 'y1' },
],
});
arrayField.swap(0, 1);
expect(formModel.values).toEqual({
arr: [
{ x: 'x1', y: 'y1' },
{ x: 'x0', y: 'y0' },
],
});
expect(formModel.getField('arr.0.x').state.errors).toEqual({
'arr.0.x': [{ name: 'arr.0.x', message: 'err1x', level: FeedbackLevel.Error }],
});
expect(formModel.getField('arr.1.x').state.errors).toEqual({
'arr.1.x': [{ name: 'arr.1.x', message: 'err0x', level: FeedbackLevel.Error }],
});
// assert form.state.errors
expect(formModel.state.errors['arr.0.x']).toEqual([
{ name: 'arr.0.x', message: 'err1x', level: FeedbackLevel.Error },
]);
expect(formModel.state.errors['arr.1.x']).toEqual([
{ name: 'arr.1.x', message: 'err0x', level: FeedbackLevel.Error },
]);
});
it('should have correct form.state.errors after swapping invalid field with valid field', () => {
const arrayField = formModel.createFieldArray('arr');
const a = arrayField!.append('a');
const b = arrayField!.append('b');
arrayField!.append('c');
formModel.init({});
b.state.errors = {
'arr.1': [{ name: 'arr.1', message: 'err1', level: FeedbackLevel.Error }],
};
arrayField.swap(0, 1);
expect(formModel.getField('arr.0').state.errors).toEqual({
'arr.0': [{ name: 'arr.0', message: 'err1', level: FeedbackLevel.Error }],
});
expect(formModel.getField('arr.1').state.errors).toEqual(undefined);
});
it('should trigger array effect and child effect', () => {
const arrayField = formModel.createFieldArray('arr');
const fieldA = arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
const arrayEffect = vi.fn();
arrayField.onValueChange(arrayEffect);
const fieldAEffect = vi.fn();
fieldA.onValueChange(fieldAEffect);
formModel.init({});
arrayField.swap(1, 2);
expect(arrayEffect).toHaveBeenCalledOnce();
expect(fieldAEffect).toHaveBeenCalledOnce();
});
});
describe('move', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('should throw error when from or to exceeds bound', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
formModel.init({});
expect(() => arrayField.move(-1, 1)).toThrowError();
expect(() => arrayField.move(1, -1)).toThrowError();
expect(() => arrayField.move(1, 3)).toThrowError();
expect(() => arrayField.move(3, 1)).toThrowError();
});
it('can move from 0 to middle index', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
formModel.init({});
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.move(0, 1);
expect(formModel.values).toEqual({ arr: ['b', 'a', 'c'] });
});
it('can move from 0 to last index', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
formModel.init({});
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.move(0, 2);
expect(formModel.values).toEqual({ arr: ['b', 'c', 'a'] });
});
it('can move from middle index to last index', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
formModel.init({});
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c'] });
arrayField.move(1, 2);
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b'] });
});
it('can move from middle index to another middle index', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
arrayField!.append('d');
formModel.init({});
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd'] });
arrayField.move(1, 2);
expect(formModel.values).toEqual({ arr: ['a', 'c', 'b', 'd'] });
});
it('can move from middle index to another middle index with more elements', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
arrayField!.append('d');
arrayField!.append('e');
arrayField!.append('f');
formModel.init({});
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd', 'e', 'f'] });
arrayField.move(1, 4);
expect(formModel.values).toEqual({ arr: ['a', 'c', 'd', 'e', 'b', 'f'] });
});
it('can move from middle index to another middle index with more elements when to is greater than from', () => {
const arrayField = formModel.createFieldArray('arr');
arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
arrayField!.append('d');
arrayField!.append('e');
arrayField!.append('f');
formModel.init({});
expect(formModel.values).toEqual({ arr: ['a', 'b', 'c', 'd', 'e', 'f'] });
arrayField.move(4, 1);
expect(formModel.values).toEqual({ arr: ['a', 'e', 'b', 'c', 'd', 'f'] });
});
it('should trigger array effect and child effect', () => {
const arrayField = formModel.createFieldArray('arr');
const fieldA = arrayField!.append('a');
arrayField!.append('b');
arrayField!.append('c');
const arrayEffect = vi.fn();
arrayField.onValueChange(arrayEffect);
const fieldAEffect = vi.fn();
fieldA.onValueChange(fieldAEffect);
formModel.init({});
arrayField.move(1, 2);
expect(arrayEffect).toHaveBeenCalledOnce();
expect(fieldAEffect).toHaveBeenCalledOnce();
});
});
});
@@ -0,0 +1,443 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Errors, FeedbackLevel, ValidateTrigger } from '@/types';
import { FormModel } from '@/core/form-model';
describe('FieldModel', () => {
let formModel = new FormModel();
describe('state', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('can bubble', () => {
formModel.createField('parent');
formModel.createField('parent.child');
const childField = formModel.getField('parent.child')!;
const parentField = formModel.getField('parent')!;
childField.value = 1;
expect(childField.state.isTouched).toBe(true);
expect(parentField.state.isTouched).toBe(true);
expect(formModel.state.isTouched).toBe(true);
});
it('can bubble with array', () => {
formModel.createField('parent');
formModel.createField('parent.arr');
formModel.createField('parent.arr.1');
const arrChild = formModel.getField('parent.arr.1')!;
const arrField = formModel.getField('parent.arr')!;
const parentField = formModel.getField('parent')!;
arrChild.value = 1;
expect(arrChild.state.isTouched).toBe(true);
expect(arrField.state.isTouched).toBe(true);
expect(parentField.state.isTouched).toBe(true);
expect(formModel.state.isTouched).toBe(true);
});
it('do not set isTouched for init value set', () => {
formModel.createField('parent');
formModel.createField('parent.child');
const childField = formModel.getField('parent.child')!;
const parentField = formModel.getField('parent')!;
expect(childField.state.isTouched).toBe(false);
expect(parentField.state.isTouched).toBe(false);
expect(formModel.state.isTouched).toBe(false);
});
});
describe('validate', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('when validate func return only a message', async () => {
formModel.init({ validate: { 'parent.*': () => 'some message' } });
formModel.createField('parent');
formModel.createField('parent.child');
const childField = formModel.getField('parent.child')!;
expect(childField.state.errors).toBeUndefined();
await childField.validate();
expect(childField.state.errors?.['parent.child'][0].message).toBe('some message');
expect(childField.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
});
it('when validate func return a FieldWarning', async () => {
formModel.init({
validate: {
'parent.*': () => ({
level: FeedbackLevel.Warning,
message: 'some message',
}),
},
});
formModel.createField('parent');
formModel.createField('parent.child');
const childField = formModel.getField('parent.child')!;
expect(childField.state.errors).toBeUndefined();
await childField.validate();
expect(childField.state.warnings?.['parent.child'][0].message).toBe('some message');
expect(childField.state.warnings?.['parent.child'][0].level).toBe(FeedbackLevel.Warning);
});
it('when validate return a FormError', async () => {
formModel.init({
validate: {
'parent.*': () => ({
level: FeedbackLevel.Error,
message: 'some message',
}),
},
});
formModel.createField('parent');
formModel.createField('parent.child');
const childField = formModel.getField('parent.child')!;
expect(childField.state.errors?.length).toBeUndefined();
await childField.validate();
expect(childField.state.errors?.['parent.child'][0].message).toBe('some message');
expect(childField.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
});
it('should bubble errors to parent field', async () => {
formModel.init({
validate: {
'parent.*': () => ({
level: FeedbackLevel.Error,
message: 'some message',
}),
},
});
formModel.createField('parent');
formModel.createField('parent.child');
const childField = formModel.getField('parent.child')!;
const parentField = formModel.getField('parent')!;
await childField.validate();
expect(parentField.state.errors?.['parent.child'][0].message).toBe('some message');
expect(parentField.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
});
it('should bubble errors to form', async () => {
formModel.init({
validate: {
'parent.*': () => ({
level: FeedbackLevel.Error,
message: 'some message',
}),
},
});
formModel.createField('parent');
formModel.createField('parent.child');
const childField = formModel.getField('parent.child')!;
await childField.validate();
expect(formModel.state.errors?.['parent.child'][0].message).toBe('some message');
expect(formModel.state.errors?.['parent.child'][0].level).toBe(FeedbackLevel.Error);
});
it('should correctly set and bubble invalid', async () => {
formModel.init({
validate: {
'parent.*': () => ({
level: FeedbackLevel.Error,
message: 'some message',
}),
},
});
const parent = formModel.createField('parent');
const child = formModel.createField('parent.child');
await child.validate();
expect(child.state.invalid).toBe(true);
expect(parent.state.invalid).toBe(true);
expect(formModel.state.invalid).toBe(true);
});
it('should validate self ancestors and child', async () => {
formModel.init({
validateTrigger: ValidateTrigger.onChange,
});
const root = formModel.createField('root');
const l1 = formModel.createField('root.l1');
const l2 = formModel.createField('root.l1.l2');
const l3 = formModel.createField('root.l1.l2.l3');
const l4 = formModel.createField('root.l1.l2.l3.l4');
const other = formModel.createField('root.other');
vi.spyOn(root, 'validate');
vi.spyOn(l1, 'validate');
vi.spyOn(l2, 'validate');
vi.spyOn(l3, 'validate');
vi.spyOn(l4, 'validate');
vi.spyOn(other, 'validate');
formModel.setValueIn('root.l1.l2', 1);
expect(root.validate).toHaveBeenCalledTimes(1);
expect(l1.validate).toHaveBeenCalledTimes(1);
expect(l2.validate).toHaveBeenCalledTimes(1);
expect(l3.validate).toHaveBeenCalledTimes(1);
expect(l4.validate).toHaveBeenCalledTimes(1);
expect(other.validate).toHaveBeenCalledTimes(0);
});
it('should validate when multiple pattern match ', async () => {
const validate1 = vi.fn();
const validate2 = vi.fn();
formModel.init({
validateTrigger: ValidateTrigger.onChange,
validate: {
'a.*.input': validate1,
'a.1.input': validate2,
},
initialValues: {
a: [{ input: '0' }, { input: '1' }],
},
});
const root = formModel.createField('a');
const i0 = formModel.createField('a.0.input');
const i1 = formModel.createField('a.1.input');
formModel.setValueIn('a.1.input', 'xxx');
expect(validate1).toHaveBeenCalledTimes(1);
expect(validate2).toHaveBeenCalledTimes(1);
});
// 暂时注释了从 parent 触发validate 的能力,所以注释这个单测
// it('can trigger validate from parent', async () => {
// formModel.init({
// validate: {
// 'parent.child1': () => ({
// level: FeedbackLevel.Error,
// message: 'error',
// }),
// 'parent.child2': () => ({
// level: FeedbackLevel.Warning,
// message: 'warning',
// }),
// },
// });
// const parent = formModel.createField('parent');
// formModel.createField('parent.child1');
// formModel.createField('parent.child2');
//
// await parent.validate();
//
// expect(formModel.state.errors?.['parent.child1'][0].message).toBe('error');
// expect(formModel.state.warnings?.['parent.child2'][0].level).toBe('warning');
// });
});
describe('onValueChange', () => {
let formEffect = vi.fn();
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
formEffect = vi.fn();
formModel.onFormValuesChange(formEffect);
});
it('should bubble value change', () => {
const parent = formModel.createField('parent');
const child1 = formModel.createField('parent.child1');
const childOnChange = vi.fn();
const parentOnChange = vi.fn();
child1.onValueChange(childOnChange);
parent.onValueChange(parentOnChange);
child1.value = 1;
expect(parentOnChange).toHaveBeenCalledTimes(1);
expect(childOnChange).toHaveBeenCalledTimes(1);
expect(formEffect).toHaveBeenCalledTimes(1);
});
it('should bubble value change in array when delete', () => {
const parent = formModel.createField('parent');
const arr = formModel.createFieldArray('parent.arr');
const item1 = formModel.createField('parent.arr.0');
const parentOnChange = vi.fn();
const arrOnChange = vi.fn();
const item1OnChange = vi.fn();
parent.onValueChange(parentOnChange);
arr.onValueChange(arrOnChange);
item1.onValueChange(item1OnChange);
formModel.setValueIn('parent.arr.0', 1);
arr.delete(0);
expect(item1OnChange).toHaveBeenCalledTimes(2);
expect(arrOnChange).toHaveBeenCalledTimes(2);
expect(parentOnChange).toHaveBeenCalledTimes(2);
});
it('should bubble value change in array when append', () => {
const parent = formModel.createField('parent');
const arr = formModel.createFieldArray('parent.arr');
const parentOnChange = vi.fn();
const arrOnChange = vi.fn();
parent.onValueChange(parentOnChange);
arr.onValueChange(arrOnChange);
arr.append('1');
expect(arrOnChange).toHaveBeenCalledTimes(1);
expect(parentOnChange).toHaveBeenCalledTimes(1);
expect(formEffect).toHaveBeenCalledTimes(1);
});
it('should not trigger child field change when array append', () => {
formModel.createField('parent');
const arr = formModel.createFieldArray('parent.arr');
const item0 = formModel.createField('parent.arr.0');
const item0x = formModel.createField('parent.arr.0.x');
const item0OnChange = vi.fn();
const item0xOnChange = vi.fn();
item0.onValueChange(item0OnChange);
item0x.onValueChange(item0xOnChange);
arr.append('1');
expect(item0OnChange).toHaveBeenCalledTimes(0);
expect(item0xOnChange).toHaveBeenCalledTimes(0);
});
it('should clear and fire change', () => {
const parent = formModel.createField('parent');
const child1 = formModel.createField('parent.child1');
const child1OnChange = vi.fn();
const parentOnChange = vi.fn();
child1.onValueChange(child1OnChange);
parent.onValueChange(parentOnChange);
formModel.setValueIn('parent.child1', 1);
child1.clear();
expect(child1OnChange).toHaveBeenCalledTimes(2);
expect(parentOnChange).toHaveBeenCalledTimes(2);
expect(formEffect).toHaveBeenCalledTimes(2);
});
it('should bubble change in array delete', () => {
const arr = formModel.createFieldArray('arr');
const child1 = formModel.createField('arr.0');
const childOnChange = vi.fn();
const arrOnChange = vi.fn();
child1.onValueChange(childOnChange);
arr.onValueChange(arrOnChange);
formModel.setValueIn('arr.0', 1);
arr.delete(0);
expect(childOnChange).toHaveBeenCalledTimes(2);
expect(arrOnChange).toHaveBeenCalledTimes(2);
// formModel.setValueIn 一次,arr.delete 中 arr 本身触发一次
expect(formEffect).toHaveBeenCalledTimes(2);
});
it('should bubble change in array append', () => {
const arr = formModel.createFieldArray('arr');
const item0 = formModel.createField('arr.0');
const item0OnChange = vi.fn();
const arrOnChange = vi.fn();
item0.onValueChange(item0OnChange);
arr.onValueChange(arrOnChange);
formModel.setValueIn('arr.0', 'a');
arr.append('b');
expect(item0OnChange).toHaveBeenCalledTimes(1);
});
it('should ignore unchanged items when array delete', () => {
const other = formModel.createField('other');
const parent = formModel.createField('parent');
const arr = formModel.createFieldArray('parent.arr');
const item0 = formModel.createField('parent.arr.0');
const item1 = formModel.createField('parent.arr.1');
const item2 = formModel.createField('parent.arr.2');
formModel.setValueIn('parent.arr', [1, 2, 3]);
const item0OnChange = vi.fn();
const item1OnChange = vi.fn();
const item2OnChange = vi.fn();
const arrOnChange = vi.fn();
const parentOnChange = vi.fn();
const otherOnChange = vi.fn();
item0.onValueChange(item0OnChange);
item1.onValueChange(item1OnChange);
item2.onValueChange(item2OnChange);
arr.onValueChange(arrOnChange);
parent.onValueChange(parentOnChange);
other.onValueChange(otherOnChange);
arr.delete(1);
expect(arrOnChange).toHaveBeenCalledTimes(1);
expect(parentOnChange).toHaveBeenCalledTimes(1);
expect(item0OnChange).not.toHaveBeenCalled();
expect(item1OnChange).toHaveBeenCalledTimes(1);
expect(item2OnChange).toHaveBeenCalledTimes(1);
expect(otherOnChange).not.toHaveBeenCalled();
});
});
describe('dispose', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('should correctly cleanup when field dispose', () => {
const parent = formModel.createField('parent');
const child1 = formModel.createField('parent.child1');
child1.state.errors = { 'parent.child1': 'errors' } as unknown as Errors;
child1.bubbleState();
expect(formModel.state.errors?.['parent.child1']).toEqual('errors');
expect(parent.state.errors?.['parent.child1']).toEqual('errors');
parent.dispose();
// Ref 'dispose' method in field-model.ts
// 1. expect state has been cleared
// expect(child1.state.errors).toBeUndefined();
// expect(parent.state.errors?.['parent.child1']).toBeUndefined();
// 2. expect field model has been cleared
expect(formModel.fieldMap.get('parent')).toBeUndefined();
expect(formModel.fieldMap.get('parent.child1')).toBeUndefined();
});
});
});
@@ -0,0 +1,309 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mapValues } from 'lodash-es';
import { ValidateTrigger } from '@/types';
import { FormModel } from '@/core/form-model';
describe('FormModel', () => {
let formModel = new FormModel();
describe('validate trigger', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('do not validate when value change if validateTrigger is onBlur', async () => {
formModel.init({ validateTrigger: ValidateTrigger.onBlur });
const field = formModel.createField('x');
field.originalValidate = vi.fn();
vi.spyOn(field, 'originalValidate');
field.value = 'some value';
expect(field.originalValidate).not.toHaveBeenCalledOnce();
});
describe('delete field', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('validate onChange', async () => {
formModel.init({ initialValues: { parent: { child1: 1 } } });
formModel.createField('parent');
formModel.createField('parent.child1');
expect(formModel.values.parent?.child1).toBe(1);
formModel.deleteField('parent');
expect(formModel.values.parent?.child1).toBeUndefined();
expect(formModel.getField('parent')).toBeUndefined();
expect(formModel.getField('parent.child1')).toBeUndefined();
});
});
});
describe('FormModel.validate', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
});
it('should run validate on all matched names', async () => {
formModel.init({
validate: {
'a.b.*': () => 'error',
},
});
const bField = formModel.createField('a.b');
const xField = formModel.createField('a.b.x');
formModel.setValueIn('a.b', { x: 1, y: 2 });
const results = await formModel.validate();
// 1. assert validate has been executed correctly
expect(results.length).toEqual(2);
expect(results[0].message).toEqual('error');
expect(results[0].name).toEqual('a.b.x');
expect(results[1].message).toEqual('error');
expect(results[1].name).toEqual('a.b.y');
// 2. assert form state has been set correctly
expect(formModel.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
// 3. assert field state has been set correctly
expect(xField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
// 4. assert field state has been bubbled to its parent
expect(bField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
});
it('should run validate if multiple patterns match', async () => {
const mockValidate1 = vi.fn();
const mockValidate2 = vi.fn();
formModel.init({
validate: {
'a.b.*': mockValidate1,
'a.b.x': mockValidate2,
},
});
const bField = formModel.createField('a.b');
const xField = formModel.createField('a.b.x');
formModel.setValueIn('a.b', { x: 1, y: 2 });
formModel.validate();
expect(mockValidate1).toHaveBeenCalledTimes(2);
expect(mockValidate2).toHaveBeenCalledTimes(1);
});
it('should run validate correctly if multiple patterns match but multiple layer empty value exist', async () => {
const mockValidate1 = vi.fn();
const mockValidate2 = vi.fn();
formModel.init({
validate: {
'a.*.x': mockValidate1,
'a.b.x': mockValidate2,
},
});
const bField = formModel.createField('a.b');
const xField = formModel.createField('a.b.x');
formModel.setValueIn('a', {});
formModel.validate();
expect(mockValidate1).toHaveBeenCalledTimes(0);
expect(mockValidate2).toHaveBeenCalledTimes(1);
});
it('should correctly set form errors state when field does not exist', async () => {
formModel.init({
validate: {
'a.b.*': () => 'error',
},
});
formModel.setValueIn('a.b', { x: 1, y: 2 });
await formModel.validate();
expect(formModel.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
});
it('should set form and field state correctly when run validate twice', async () => {
formModel.init({
validate: {
'a.b.*': ({ value }) => (typeof value === 'string' ? undefined : 'error'),
},
});
const bField = formModel.createField('a.b');
const xField = formModel.createField('a.b.x');
formModel.setValueIn('a.b', { x: 1, y: 2 });
let results = await formModel.validate();
// both x y is string, so 2 errors
expect(results.length).toEqual(2);
expect(formModel.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
expect(formModel.state?.errors?.['a.b.y']?.[0].message).toEqual('error');
expect(xField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
expect(bField.state?.errors?.['a.b.x']?.[0].message).toEqual('error');
formModel.setValueIn('a.b', { x: '1', y: '2' });
results = await formModel.validate();
expect(results.length).toEqual(0);
expect(formModel.state?.errors?.['a.b.x']).toEqual([]);
expect(formModel.state?.errors?.['a.b.y']).toEqual([]);
});
it('validate as dynamic function', async () => {
formModel.init({
initialValues: { a: 3, b: 'str' },
validate: (v, ctx) => {
expect(ctx).toEqual('context');
return mapValues(v, (value) => {
if (typeof value === 'string') {
return () => 'string error';
}
return () => 'num error';
});
},
context: 'context',
});
const fieldResult = await formModel.validateIn('a');
expect(fieldResult).toEqual(['num error']);
const results = await formModel.validate();
expect(results).toEqual([
{ name: 'a', message: 'num error', level: 'error' },
{ name: 'b', message: 'string error', level: 'error' },
]);
});
});
describe('FormModel set/get values', () => {
beforeEach(() => {
formModel.dispose();
formModel = new FormModel();
vi.spyOn(formModel.onFormValuesInitEmitter, 'fire');
vi.spyOn(formModel.onFormValuesChangeEmitter, 'fire');
vi.spyOn(formModel.onFormValuesUpdatedEmitter, 'fire');
});
it('should set value for root path', () => {
formModel.init({
initialValues: {
a: 1,
},
});
formModel.values = { a: 2 };
expect(formModel.values).toEqual({ a: 2 });
});
it('should set initialValues and fire init and updated events', async () => {
formModel.init({
initialValues: {
a: 1,
},
});
expect(formModel.values).toEqual({ a: 1 });
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledWith({
values: {
a: 1,
},
name: '',
});
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledWith({
values: {
a: 1,
},
name: '',
});
});
it('should set initialValues in certain path and fire change', async () => {
formModel.init({
initialValues: {
a: 1,
},
});
formModel.setInitValueIn('b', 2);
expect(formModel.values).toEqual({ a: 1, b: 2 });
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledWith({
values: {
a: 1,
b: 2,
},
prevValues: {
a: 1,
},
name: 'b',
});
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledWith({
values: {
a: 1,
b: 2,
},
prevValues: {
a: 1,
},
name: 'b',
});
});
it('should not set initialValues in certain path if value exists', async () => {
formModel.init({
initialValues: {
a: 1,
},
});
formModel.setInitValueIn('a', 2);
expect(formModel.values).toEqual({ a: 1 });
// 仅在初始化时调用一次,setInitValueIn 没有调用
expect(formModel.onFormValuesInitEmitter.fire).toHaveBeenCalledTimes(1);
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledTimes(1);
});
it('should set values in certain path and fire change and updated events', async () => {
formModel.init({
initialValues: {
a: 1,
},
});
formModel.setValueIn('a', 2);
expect(formModel.values).toEqual({ a: 2 });
// 仅在初始化时调用一次,setInitValueIn 没有调用
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledTimes(1);
// 初始化一次,变更值一次,所以是两次
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledTimes(2);
expect(formModel.onFormValuesChangeEmitter.fire).toHaveBeenCalledWith({
values: {
a: 2,
},
prevValues: {
a: 1,
},
name: 'a',
});
expect(formModel.onFormValuesUpdatedEmitter.fire).toHaveBeenCalledWith({
values: {
a: 2,
},
prevValues: {
a: 1,
},
name: 'a',
});
});
});
});
@@ -0,0 +1,456 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
// test src/glob.ts
import { describe, expect, it } from 'vitest';
import { Glob } from '../src/utils/glob';
describe('glob', () => {
describe('isMatch', () => {
it('* at the end', () => {
expect(Glob.isMatch('a.b.*', 'a.b.c')).toBe(true);
expect(Glob.isMatch('a.b.*', 'a.k.c')).toBe(false);
});
it('* at the start', () => {
expect(Glob.isMatch('*.b.c', 'a.b.c')).toBe(true);
expect(Glob.isMatch('*.b.c', 'a.b.x')).toBe(false);
});
it('multiple *', () => {
expect(Glob.isMatch('*.b.*', 'a.b.c')).toBe(true);
expect(Glob.isMatch('a.b.*', 'a.k.c')).toBe(false);
});
it('no *', () => {
expect(Glob.isMatch('a.b.c', 'a.b.c')).toBe(true);
});
it('length not match', () => {
expect(Glob.isMatch('a.b.*', 'a.b.c.c')).toBe(false);
});
});
describe('isMatchOrParent', () => {
it('* at the end', () => {
expect(Glob.isMatchOrParent('a.b.*', 'a.b.c')).toBe(true);
expect(Glob.isMatchOrParent('a.b.*', 'a.k.c')).toBe(false);
});
it('* at the start', () => {
expect(Glob.isMatchOrParent('*.b.c', 'a.b.c')).toBe(true);
expect(Glob.isMatchOrParent('*.b.c', 'a.b.x')).toBe(false);
expect(Glob.isMatchOrParent('*.b', 'a.b.x')).toBe(true);
});
it('multiple *', () => {
expect(Glob.isMatchOrParent('*.b.*', 'a.b.c')).toBe(true);
expect(Glob.isMatchOrParent('*.b.*', 'a.k.c')).toBe(false);
});
it('no *', () => {
expect(Glob.isMatchOrParent('a.b.c', 'a.b.c')).toBe(true);
expect(Glob.isMatchOrParent('a.b', 'a.b.c')).toBe(true);
expect(Glob.isMatchOrParent('a', 'a.b.c')).toBe(true);
expect(Glob.isMatchOrParent('', 'a.b.c.')).toBe(true);
});
it('length not match', () => {
expect(Glob.isMatchOrParent('a.b.*', 'a.b.c.c')).toBe(true);
expect(Glob.isMatchOrParent('a.b.c.d', 'a.b.c.')).toBe(false);
});
});
describe('getParentPathByPattern', () => {
it('should get parent path correctly', () => {
expect(Glob.getParentPathByPattern('a.b.*', 'a.b.c')).toBe('a.b.c');
expect(Glob.getParentPathByPattern('a.b.*', 'a.b.c.d')).toBe('a.b.c');
expect(Glob.getParentPathByPattern('a.b', 'a.b.c.d')).toBe('a.b');
expect(Glob.getParentPathByPattern('a.*.c', 'a.b.c.d')).toBe('a.b.c');
});
});
describe('findMatchPaths', () => {
it('return original path array if no *', () => {
const obj = { a: { b: { c: 1 } } };
expect(Glob.findMatchPaths(obj, 'a.b.c')).toEqual(['a.b.c']);
});
it('object: when * is in middle of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPaths(obj, 'a.*.c')).toEqual(['a.b.c']);
});
it('object:when * is at the end of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPaths(obj, 'a.*')).toEqual(['a.b']);
});
// 暂时不支持该场景,见glob.ts 中 143行说明
it('object: * 后面数据异构', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPaths(obj, '*.y')).toEqual(['x.y']);
});
it('object:when * is at the start and end of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPaths(obj, '*.y.*')).toEqual(['x.y.z']);
});
it('array: when * is at the end of the path', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
],
};
expect(Glob.findMatchPaths(obj, 'arr.*')).toEqual(['arr.0', 'arr.1']);
});
it('array: when * is at the start of the path', () => {
const arr = [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
];
expect(Glob.findMatchPaths(arr, '*')).toEqual(['0', '1']);
});
it('array: when * is in the middle of the path', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
],
};
expect(Glob.findMatchPaths(obj, 'arr.*.y')).toEqual(['arr.0.y', 'arr.1.y']);
});
it('array in array: when double * ', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: ['1', '2'],
},
],
};
expect(Glob.findMatchPaths(obj, 'arr.*.y.*')).toEqual(['arr.0.y.0', 'arr.0.y.1']);
});
it('array in object: when double * ', () => {
const obj = {
x: 100,
y: {
arr: [1, 2],
},
};
expect(Glob.findMatchPaths(obj, 'y.*.*')).toEqual(['y.arr.0', 'y.arr.1']);
});
it('array in object: when double * start ', () => {
const obj = {
x: 100,
y: {
arr: [{ a: 1, b: 2 }],
},
};
expect(Glob.findMatchPaths(obj, '*.arr.*')).toEqual(['y.arr.0']);
});
it('when value after * is empty string ', () => {
const obj = {
$$input_decorator$$: {
inputParameters: [{ name: '', input: 2 }],
},
};
expect(Glob.findMatchPaths(obj, '$$input_decorator$$.inputParameters.*.name')).toEqual([
'$$input_decorator$$.inputParameters.0.name',
]);
});
it('when value after * is undefined ', () => {
const obj = {
x: {
arr: [{ name: undefined, input: 2 }],
},
};
expect(Glob.findMatchPaths(obj, 'x.arr.*.name')).toEqual(['x.arr.0.name']);
});
it('when value not directly after * is undefined ', () => {
const obj = {
x: {
arr: [{ name: { a: undefined }, input: 2 }],
},
};
expect(Glob.findMatchPaths(obj, 'x.arr.*.name.a')).toEqual(['x.arr.0.name.a']);
});
});
describe('splitPattern', () => {
it('should splict pattern correctly', () => {
expect(Glob.splitPattern('a.b.*.c.*.d')).toEqual(['a.b', '*', 'c', '*', 'd']);
expect(Glob.splitPattern('a.b.*.c.*')).toEqual(['a.b', '*', 'c', '*']);
expect(Glob.splitPattern('a.b.*.*.*.d')).toEqual(['a.b', '*', '*', '*', 'd']);
expect(Glob.splitPattern('*.*.c.*.d')).toEqual(['*', '*', 'c', '*', 'd']);
});
});
describe('getSubPaths', () => {
it('should get sub paths for valid object', () => {
const obj = {
a: {
b: {
x1: {
y1: 1,
},
x2: {
y2: 2,
},
},
},
};
expect(Glob.getSubPaths(['a.b'], obj)).toEqual(['a.b.x1', 'a.b.x2']);
expect(Glob.getSubPaths(['a.b', 'a.b.x1'], obj)).toEqual(['a.b.x1', 'a.b.x2', 'a.b.x1.y1']);
});
it('should get sub paths for array', () => {
const obj = {
a: {
b: {
x1: [1, 2],
},
},
};
expect(Glob.getSubPaths(['a.b.x1'], obj)).toEqual(['a.b.x1.0', 'a.b.x1.1']);
});
it('should get sub paths when root obj is array', () => {
const obj = [
{
x1: [1, 2],
},
{
x2: [1, 2],
},
];
expect(Glob.getSubPaths(['0.x1'], obj)).toEqual(['0.x1.0', '0.x1.1']);
});
it('should return empty array when obj is not object nor array', () => {
expect(Glob.getSubPaths(['x.y'], 1)).toEqual([]);
expect(Glob.getSubPaths(['x.y'], 'x')).toEqual([]);
expect(Glob.getSubPaths(['x.y'], undefined)).toEqual([]);
});
it('should return empty array when obj has no value for given path', () => {
const obj = {
a: {
b: {
x1: [1, 2],
},
},
};
expect(Glob.getSubPaths(['a.b.c'], obj)).toEqual([]);
});
});
describe('findMatchPathsWithEmptyValue', () => {
it('return original path array if no *', () => {
const obj = { a: { b: { c: 1 } } };
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c')).toEqual(['a.b.c']);
});
it('return original path array if no * and value is empty on multiple layers', () => {
const obj = { a: {} };
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c')).toEqual(['a.b.c']);
});
it('return original path array if no * and value is empty on multiple layers', () => {
const obj = { a: { b: {} } };
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.x.y')).toEqual(['a.x.y']);
});
it('return array with original path even if path does not exists, but the original path does not contain * ', () => {
const obj = { a: { b: { c: 1 } } };
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c.d')).toEqual(['a.b.c.d']);
});
it('return original path array if no * and path related value is undefined in object', () => {
const obj = { a: { b: { c: {} } } };
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.b.c.d')).toEqual(['a.b.c.d']);
});
it('object: when * is in middle of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.*.c')).toEqual(['a.b.c']);
});
it('object:when * is at the end of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'a.*')).toEqual(['a.b']);
});
// 暂时不支持该场景,见glob.ts 中 143行说明
it('object: * 后面数据异构', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.y')).toEqual(['a.y', 'x.y']);
});
it('object:when * is at the start and end of the path', () => {
const obj = {
a: { b: { c: 1 } },
x: { y: { z: 2 } },
};
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.y.*')).toEqual(['x.y.z']);
});
it('array: when * is at the end of the path', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
],
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'arr.*')).toEqual(['arr.0', 'arr.1']);
});
it('array: when * is at the start of the path', () => {
const arr = [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
];
expect(Glob.findMatchPathsWithEmptyValue(arr, '*')).toEqual(['0', '1']);
});
it('array: when * is in the middle of the path', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: { a: 1, b: 2 },
},
{
x: 10,
y: {
a: 10,
b: 20,
},
},
],
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'arr.*.y')).toEqual(['arr.0.y', 'arr.1.y']);
});
it('array: when data related to path is undefined', () => {
const obj = [{ a: 1 }, { a: 2, b: 3 }];
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.b')).toEqual(['0.b', '1.b']);
});
it('array in array: when double * ', () => {
const obj = {
other: 100,
arr: [
{
x: 1,
y: ['1', '2'],
},
],
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'arr.*.y.*')).toEqual([
'arr.0.y.0',
'arr.0.y.1',
]);
});
it('array in object: when double * ', () => {
const obj = {
x: 100,
y: {
arr: [1, 2],
},
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'y.*.*')).toEqual(['y.arr.0', 'y.arr.1']);
});
it('array in object: when double * start ', () => {
const obj = {
x: 100,
y: {
arr: [{ a: 1, b: 2 }],
},
};
expect(Glob.findMatchPathsWithEmptyValue(obj, '*.arr.*')).toEqual(['y.arr.0']);
});
it('when value after * is empty string ', () => {
const obj = {
$$input_decorator$$: {
inputParameters: [{ name: '', input: 2 }],
},
};
expect(
Glob.findMatchPathsWithEmptyValue(obj, '$$input_decorator$$.inputParameters.*.name')
).toEqual(['$$input_decorator$$.inputParameters.0.name']);
});
it('when value after * is undefined ', () => {
const obj = {
x: {
arr: [{ name: undefined, input: 2 }],
},
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'x.arr.*.name')).toEqual(['x.arr.0.name']);
});
it('when value not directly after * is undefined ', () => {
const obj = {
x: {
arr: [{ name: { a: undefined }, input: 2 }],
},
};
expect(Glob.findMatchPathsWithEmptyValue(obj, 'x.arr.*.name.a')).toEqual(['x.arr.0.name.a']);
});
});
});
@@ -0,0 +1,249 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, it } from 'vitest';
import { getIn, isEmptyArray, isNaN, isPromise, shallowSetIn } from '../src/utils';
describe('object', () => {
describe('isEmptyArray', () => {
it('returns true when an empty array is passed in', () => {
expect(isEmptyArray([])).toBe(true);
});
it('returns false when anything other than empty array is passed in', () => {
expect(isEmptyArray()).toBe(false);
expect(isEmptyArray(null)).toBe(false);
expect(isEmptyArray(123)).toBe(false);
expect(isEmptyArray('abc')).toBe(false);
expect(isEmptyArray({})).toBe(false);
expect(isEmptyArray({ a: 1 })).toBe(false);
expect(isEmptyArray(['abc'])).toBe(false);
});
});
describe('getIn', () => {
const obj = {
a: {
b: 2,
c: false,
d: null,
},
t: true,
s: 'a random string',
};
it('gets a value by array path', () => {
expect(getIn(obj, ['a', 'b'])).toBe(2);
});
it('gets a value by string path', () => {
expect(getIn(obj, 'a.b')).toBe(2);
});
it('return "undefined" if value was not found using given path', () => {
expect(getIn(obj, 'a.z')).toBeUndefined();
});
it('return "undefined" if value was not found using given path and an intermediate value is "false"', () => {
expect(getIn(obj, 'a.c.z')).toBeUndefined();
});
it('return "undefined" if value was not found using given path and an intermediate value is "null"', () => {
expect(getIn(obj, 'a.d.z')).toBeUndefined();
});
it('return "undefined" if value was not found using given path and an intermediate value is "true"', () => {
expect(getIn(obj, 't.z')).toBeUndefined();
});
it('return "undefined" if value was not found using given path and an intermediate value is a string', () => {
expect(getIn(obj, 's.z')).toBeUndefined();
});
});
describe('shallowSetIn', () => {
it('sets flat value', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'flat', 'value');
expect(obj).toEqual({ x: 'y' });
expect(newObj).toEqual({ x: 'y', flat: 'value' });
});
it('keep the same object if nothing is changed', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'x', 'y');
expect(obj).toBe(newObj);
});
it('keep key shen set undefined', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'x', undefined);
expect(obj).toEqual({ x: 'y' });
expect(newObj).toEqual({ x: undefined });
expect(Object.keys(newObj)).toEqual(['x']);
});
it('sets nested value', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'nested.value', 'nested value');
expect(obj).toEqual({ x: 'y' });
expect(newObj).toEqual({ x: 'y', nested: { value: 'nested value' } });
});
it('updates nested value', () => {
const obj = { x: 'y', nested: { value: 'a' } };
const newObj = shallowSetIn(obj, 'nested.value', 'b');
expect(obj).toEqual({ x: 'y', nested: { value: 'a' } });
expect(newObj).toEqual({ x: 'y', nested: { value: 'b' } });
});
it('updates deep nested value', () => {
const obj = { x: 'y', twofoldly: { nested: { value: 'a' } } };
const newObj = shallowSetIn(obj, 'twofoldly.nested.value', 'b');
expect(obj.twofoldly.nested === newObj.twofoldly.nested).toEqual(false); // fails, same object still
expect(obj).toEqual({ x: 'y', twofoldly: { nested: { value: 'a' } } }); // fails, it's b here, too
expect(newObj).toEqual({ x: 'y', twofoldly: { nested: { value: 'b' } } }); // works ofc
});
it('shallow clone data along the update path', () => {
const obj = {
x: 'y',
twofoldly: { nested: ['a', { c: 'd' }] },
other: { nestedOther: 'o' },
};
const newObj = shallowSetIn(obj, 'twofoldly.nested.0', 'b');
// All new objects/arrays created along the update path.
expect(obj).not.toBe(newObj);
expect(obj.twofoldly).not.toBe(newObj.twofoldly);
expect(obj.twofoldly.nested).not.toBe(newObj.twofoldly.nested);
// All other objects/arrays copied, not cloned (retain same memory
// location).
expect(obj.other).toBe(newObj.other);
expect(obj.twofoldly.nested[1]).toBe(newObj.twofoldly.nested[1]);
});
it('sets new array', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'nested.0', 'value');
expect(obj).toEqual({ x: 'y' });
expect(newObj).toEqual({ x: 'y', nested: ['value'] });
});
it('sets new array when item is empty string', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'nested.0', '');
expect(obj).toEqual({ x: 'y' });
expect(newObj).toEqual({ x: 'y', nested: [''] });
});
it('sets new array when item is empty string', () => {
const obj = {};
const newObj = shallowSetIn(obj, 'nested.0', '');
expect(obj).toEqual({});
expect(newObj).toEqual({ nested: [''] });
});
it('updates nested array value', () => {
const obj = { x: 'y', nested: ['a'] };
const newObj = shallowSetIn(obj, 'nested[0]', 'b');
expect(obj).toEqual({ x: 'y', nested: ['a'] });
expect(newObj).toEqual({ x: 'y', nested: ['b'] });
});
it('adds new item to nested array', () => {
const obj = { x: 'y', nested: ['a'] };
const newObj = shallowSetIn(obj, 'nested.1', 'b');
expect(obj).toEqual({ x: 'y', nested: ['a'] });
expect(newObj).toEqual({ x: 'y', nested: ['a', 'b'] });
});
it('sticks to object with int key when defined', () => {
const obj = { x: 'y', nested: { 0: 'a' } };
const newObj = shallowSetIn(obj, 'nested.0', 'b');
expect(obj).toEqual({ x: 'y', nested: { 0: 'a' } });
expect(newObj).toEqual({ x: 'y', nested: { 0: 'b' } });
});
it('supports bracket path', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'nested[0]', 'value');
expect(obj).toEqual({ x: 'y' });
expect(newObj).toEqual({ x: 'y', nested: ['value'] });
});
it('supports path containing key of the object', () => {
const obj = { x: 'y' };
const newObj = shallowSetIn(obj, 'a.x.c', 'value');
expect(obj).toEqual({ x: 'y' });
expect(newObj).toEqual({ x: 'y', a: { x: { c: 'value' } } });
});
// This case is not used in form sdk for nowso we comment it.
// it('should keep class inheritance for the top level object', () => {
// class TestClass {
// constructor(public key: string, public setObj?: any) {}
// }
// const obj = new TestClass('value');
// const newObj = shallowSetIn(obj, 'setObj.nested', 'shallowSetInValue');
// expect(obj).toEqual(new TestClass('value'));
// expect(newObj).toEqual({
// key: 'value',
// setObj: { nested: 'shallowSetInValue' },
// });
// expect(obj instanceof TestClass).toEqual(true);
// expect(newObj instanceof TestClass).toEqual(true);
// });
it('can convert primitives to objects before setting', () => {
const obj = { x: [{ y: true }] };
const newObj = shallowSetIn(obj, 'x.0.y.z', true);
expect(obj).toEqual({ x: [{ y: true }] });
expect(newObj).toEqual({ x: [{ y: { z: true } }] });
});
it('set undefined value with unknown key', () => {
const obj = { a: '' };
let newObj = shallowSetIn(obj, 'a', undefined);
newObj = shallowSetIn(newObj, 'b', undefined);
expect(obj).toEqual({ a: '' });
expect(newObj).toEqual({ a: undefined, b: undefined });
});
});
describe('isPromise', () => {
it('verifies that a value is a promise', () => {
const alwaysResolve = (resolve: Function) => resolve();
const promise = new Promise(alwaysResolve);
expect(isPromise(promise)).toEqual(true);
});
it('verifies that a value is not a promise', () => {
const emptyObject = {};
const identity = (i: any) => i;
const foo = 'foo';
const answerToLife = 42;
expect(isPromise(emptyObject)).toEqual(false);
expect(isPromise(identity)).toEqual(false);
expect(isPromise(foo)).toEqual(false);
expect(isPromise(answerToLife)).toEqual(false);
expect(isPromise(undefined)).toEqual(false);
expect(isPromise(null)).toEqual(false);
});
});
describe('isNaN', () => {
it('correctly validate NaN', () => {
expect(isNaN(NaN)).toBe(true);
});
it('correctly validate not NaN', () => {
expect(isNaN(undefined)).toBe(false);
expect(isNaN(1)).toBe(false);
expect(isNaN('')).toBe(false);
expect(isNaN([])).toBe(false);
});
});
});
@@ -0,0 +1,80 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, it } from 'vitest';
import { Path } from '../src/core/path';
describe('path', () => {
it('toString', () => {
expect(new Path('a.b.c').toString()).toEqual('a.b.c');
expect(new Path('a.b[0].c').toString()).toEqual('a.b.0.c');
expect(new Path(['a', 'b', 'c']).toString()).toEqual('a.b.c');
});
it('parent', () => {
expect(new Path('a.b.c').parent!.toString()).toEqual('a.b');
expect(new Path('a.b[0]').parent!.toString()).toEqual('a.b');
expect(new Path('a').parent).toEqual(undefined);
});
it('isChild', () => {
expect(new Path('a.b').isChild('a.b.c')).toEqual(true);
expect(new Path('a.b').isChild('a.b[0]')).toEqual(true);
});
it('concat', () => {
expect(new Path('a').concat('b').toString()).toEqual('a.b');
expect(new Path('a').concat('b.c').toString()).toEqual('a.b.c');
expect(new Path('a').concat(0).toString()).toEqual('a.0');
expect(() => {
new Path('a').concat({} as any);
}).toThrowError(/invalid param type/);
});
it('compareArrayPath', () => {
expect((Path.compareArrayPath(new Path('a.b.0'), new Path('a.b.1')) as number) < 0).toBe(true);
expect((Path.compareArrayPath(new Path('a.b.0'), new Path('a.b.2')) as number) < 0).toBe(true);
expect((Path.compareArrayPath(new Path('a.b.1'), new Path('a.b.0')) as number) < 0).toBe(false);
expect((Path.compareArrayPath(new Path('a.b.0'), new Path('a.b.0')) as number) === 0).toBe(
true
);
expect((Path.compareArrayPath(new Path('a.b.1'), new Path('a.b.0.x')) as number) < 0).toBe(
false
);
expect((Path.compareArrayPath(new Path('a.b.1.y'), new Path('a.b.0.x')) as number) < 0).toBe(
false
);
expect(() => Path.compareArrayPath(new Path('a.1'), new Path('a.b.0'))).toThrowError();
expect(() => Path.compareArrayPath(new Path(''), new Path(''))).toThrowError();
expect(() => Path.compareArrayPath(new Path('a.b.c'), new Path('a.b'))).toThrowError();
});
it('isChildOrGrandChild', () => {
expect(new Path('a.b').isChildOrGrandChild('a.b.c')).toEqual(true);
expect(new Path('a.b').isChildOrGrandChild('a.b[0]')).toEqual(true);
expect(new Path('a.b').isChildOrGrandChild('a.b.1')).toEqual(true);
expect(new Path('a.b').isChildOrGrandChild('a.b')).toEqual(false);
expect(new Path('a.b').isChildOrGrandChild('a')).toEqual(false);
expect(new Path('').isChildOrGrandChild('a')).toEqual(true);
});
it('replaceParent', () => {
expect(new Path('a.b.c.d').replaceParent(new Path('a.b'), new Path('x.y')).toString()).toEqual(
'x.y.c.d'
);
expect(new Path('a.b.0.d').replaceParent(new Path('a.b'), new Path('x.y')).toString()).toEqual(
'x.y.0.d'
);
expect(new Path('0.d').replaceParent(new Path(''), new Path('x.y')).toString()).toEqual(
'x.y.0.d'
);
expect(
new Path('a.b.c').replaceParent(new Path('a.b.c'), new Path('x.y.z')).toString()
).toEqual('x.y.z');
expect(() =>
new Path('a.b.0.d').replaceParent(new Path('a1.b'), new Path('x.y')).toString()
).toThrowError();
expect(() =>
new Path('a.0.d').replaceParent(new Path('a.0.d.e'), new Path('x.y')).toString()
).toThrowError();
});
});
@@ -0,0 +1,202 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { beforeEach, describe, expect, it } from 'vitest';
import { toFieldArray } from '@/core/to-field-array';
import { FormModel } from '@/core/form-model';
import { FieldArrayModel } from '@/core/field-array-model';
describe('toFieldArray', () => {
let formModel: FormModel;
let fieldArrayModel: FieldArrayModel;
beforeEach(() => {
formModel = new FormModel();
formModel.init({});
formModel.createFieldArray('users');
fieldArrayModel = formModel.getField<FieldArrayModel>('users')!;
});
it('should convert FieldArrayModel to FieldArray', () => {
const fieldArray = toFieldArray(fieldArrayModel);
expect(fieldArray).toBeDefined();
expect(fieldArray.name).toBe('users');
expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
});
it('should expose key property from model id', () => {
const fieldArray = toFieldArray(fieldArrayModel);
expect(fieldArray.key).toBe(fieldArrayModel.id);
});
it('should expose name property from model path', () => {
const fieldArray = toFieldArray(fieldArrayModel);
expect(fieldArray.name).toBe('users');
expect(fieldArray.name).toBe(fieldArrayModel.path.toString());
});
it('should expose value property from model value', () => {
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }];
const fieldArray = toFieldArray(fieldArrayModel);
expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Bob' }]);
expect(fieldArray.value).toBe(fieldArrayModel.value);
});
it('should update model value via onChange', () => {
const fieldArray = toFieldArray(fieldArrayModel);
const newValue = [{ name: 'Charlie' }];
fieldArray.onChange(newValue);
expect(fieldArrayModel.value).toEqual(newValue);
expect(fieldArray.value).toEqual(newValue);
});
it('should map over array elements correctly', () => {
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }];
const fieldArray = toFieldArray(fieldArrayModel);
const mapped = fieldArray.map((field, index) => {
expect(field).toBeDefined();
expect(field.name).toBe(`users.${index}`);
return field.value;
});
expect(mapped).toHaveLength(2);
expect(mapped).toEqual([{ name: 'Alice' }, { name: 'Bob' }]);
});
it('should append new item and return Field', () => {
const fieldArray = toFieldArray(fieldArrayModel);
const newItem = { name: 'Charlie' };
const newField = fieldArray.append(newItem);
expect(newField).toBeDefined();
expect(newField.name).toBe('users.0');
expect(newField.value).toEqual(newItem);
expect(fieldArrayModel.value).toEqual([newItem]);
});
it('should delete item by index', () => {
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
const fieldArray = toFieldArray(fieldArrayModel);
fieldArray.delete(1);
expect(fieldArrayModel.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
});
it('should remove item by index (same as delete)', () => {
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
const fieldArray = toFieldArray(fieldArrayModel);
fieldArray.remove(1);
expect(fieldArrayModel.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
expect(fieldArray.value).toEqual([{ name: 'Alice' }, { name: 'Charlie' }]);
});
it('should swap items at two indices', () => {
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
const fieldArray = toFieldArray(fieldArrayModel);
fieldArray.swap(0, 2);
expect(fieldArrayModel.value).toEqual([
{ name: 'Charlie' },
{ name: 'Bob' },
{ name: 'Alice' },
]);
expect(fieldArray.value).toEqual([{ name: 'Charlie' }, { name: 'Bob' }, { name: 'Alice' }]);
});
it('should move item from one index to another', () => {
fieldArrayModel.value = [{ name: 'Alice' }, { name: 'Bob' }, { name: 'Charlie' }];
const fieldArray = toFieldArray(fieldArrayModel);
fieldArray.move(0, 2);
expect(fieldArrayModel.value).toEqual([
{ name: 'Bob' },
{ name: 'Charlie' },
{ name: 'Alice' },
]);
expect(fieldArray.value).toEqual([{ name: 'Bob' }, { name: 'Charlie' }, { name: 'Alice' }]);
});
it('should hide _fieldModel property (non-enumerable)', () => {
const fieldArray = toFieldArray(fieldArrayModel);
// _fieldModel should exist but not be enumerable
expect((fieldArray as any)._fieldModel).toBe(fieldArrayModel);
expect(Object.keys(fieldArray)).not.toContain('_fieldModel');
});
it('should support complex nested operations', () => {
const fieldArray = toFieldArray(fieldArrayModel);
// Append multiple items
fieldArray.append({ name: 'Alice', age: 30 });
fieldArray.append({ name: 'Bob', age: 25 });
fieldArray.append({ name: 'Charlie', age: 35 });
expect(fieldArray.value).toHaveLength(3);
// Map and modify
const names = fieldArray.map((field) => field.value.name);
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
// Swap
fieldArray.swap(0, 1);
expect(fieldArray.value[0].name).toBe('Bob');
expect(fieldArray.value[1].name).toBe('Alice');
// Remove
fieldArray.remove(2);
expect(fieldArray.value).toHaveLength(2);
// Move
fieldArray.move(1, 0);
expect(fieldArray.value[0].name).toBe('Alice');
expect(fieldArray.value[1].name).toBe('Bob');
});
it('should work with empty array', () => {
const fieldArray = toFieldArray(fieldArrayModel);
// Value might be undefined or empty array initially
expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
const mapped = fieldArray.map((field) => field.value);
expect(Array.isArray(mapped)).toBe(true);
expect(mapped.length === 0 || mapped.length > 0).toBe(true);
});
it('should preserve reactivity through getters', () => {
const fieldArray = toFieldArray(fieldArrayModel);
// Initial value might be undefined or empty array
expect(fieldArray.value === undefined || Array.isArray(fieldArray.value)).toBe(true);
// Modify through model
fieldArrayModel.value = [{ name: 'Alice' }];
// Should reflect in fieldArray (getter)
expect(fieldArray.value).toEqual([{ name: 'Alice' }]);
// Modify through fieldArray
fieldArray.onChange([{ name: 'Bob' }]);
// Should reflect in model
expect(fieldArrayModel.value).toEqual([{ name: 'Bob' }]);
});
});
@@ -0,0 +1,331 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ValidateTrigger } from '@/types';
import { toField, toFieldState } from '@/core/to-field';
import { FormModel } from '@/core/form-model';
import { FieldModel } from '@/core/field-model';
describe('toField', () => {
let formModel: FormModel;
let fieldModel: FieldModel;
beforeEach(() => {
formModel = new FormModel();
formModel.init({});
fieldModel = formModel.createField('username') as FieldModel;
});
it('should convert FieldModel to Field', () => {
const field = toField(fieldModel);
expect(field).toBeDefined();
expect(field.name).toBe('username');
expect(field.value).toBeUndefined();
});
it('should expose name property from model', () => {
const field = toField(fieldModel);
expect(field.name).toBe(fieldModel.name);
});
it('should expose value property from model', () => {
fieldModel.value = 'John';
const field = toField(fieldModel);
expect(field.value).toBe('John');
expect(field.value).toBe(fieldModel.value);
});
describe('onChange', () => {
it('should update model value with plain value', () => {
const field = toField(fieldModel);
field.onChange('Alice');
expect(fieldModel.value).toBe('Alice');
expect(field.value).toBe('Alice');
});
it('should handle React change event for input', () => {
const field = toField(fieldModel);
const mockEvent = {
target: {
value: 'Bob',
},
} as React.ChangeEvent<HTMLInputElement>;
field.onChange(mockEvent);
expect(fieldModel.value).toBe('Bob');
});
it('should handle React change event for checkbox (checked)', () => {
const field = toField(fieldModel);
const mockEvent = {
target: {
type: 'checkbox',
checked: true,
value: 'on',
},
} as React.ChangeEvent<HTMLInputElement>;
field.onChange(mockEvent);
expect(fieldModel.value).toBe(true);
});
it('should handle React change event for checkbox (unchecked)', () => {
const field = toField(fieldModel);
const mockEvent = {
target: {
type: 'checkbox',
checked: false,
value: 'on',
},
} as React.ChangeEvent<HTMLInputElement>;
field.onChange(mockEvent);
expect(fieldModel.value).toBe(false);
});
it('should handle numeric value', () => {
const field = toField(fieldModel);
field.onChange(42);
expect(fieldModel.value).toBe(42);
});
it('should handle object value', () => {
const field = toField(fieldModel);
const objValue = { name: 'test', value: 123 };
field.onChange(objValue);
expect(fieldModel.value).toEqual(objValue);
});
it('should handle array value', () => {
const field = toField(fieldModel);
const arrValue = ['a', 'b', 'c'];
field.onChange(arrValue);
expect(fieldModel.value).toEqual(arrValue);
});
});
describe('onBlur', () => {
it('should call validate when validateTrigger is onBlur', () => {
formModel.dispose();
formModel = new FormModel();
formModel.init({ validateTrigger: ValidateTrigger.onBlur });
fieldModel = formModel.createField('username') as FieldModel;
const validateSpy = vi.spyOn(fieldModel, 'validate');
const field = toField(fieldModel);
field.onBlur?.();
expect(validateSpy).toHaveBeenCalled();
});
it('should not trigger validation when validateTrigger is not onBlur', () => {
formModel.dispose();
formModel = new FormModel();
formModel.init({ validateTrigger: ValidateTrigger.onChange });
fieldModel = formModel.createField('username') as FieldModel;
const validateSpy = vi.spyOn(fieldModel, 'validate');
const field = toField(fieldModel);
field.onBlur?.();
expect(validateSpy).not.toHaveBeenCalled();
});
it('should not trigger validation when validateTrigger is onSubmit', () => {
formModel.dispose();
formModel = new FormModel();
formModel.init({ validateTrigger: ValidateTrigger.onSubmit });
fieldModel = formModel.createField('username') as FieldModel;
const validateSpy = vi.spyOn(fieldModel, 'validate');
const field = toField(fieldModel);
field.onBlur?.();
expect(validateSpy).not.toHaveBeenCalled();
});
});
describe('onFocus', () => {
it('should set isTouched to true', () => {
const field = toField(fieldModel);
expect(fieldModel.state.isTouched).toBe(false);
field.onFocus?.();
expect(fieldModel.state.isTouched).toBe(true);
});
it('should set isTouched only once', () => {
const field = toField(fieldModel);
field.onFocus?.();
expect(fieldModel.state.isTouched).toBe(true);
field.onFocus?.();
expect(fieldModel.state.isTouched).toBe(true);
});
});
it('should expose key property (non-enumerable)', () => {
const field = toField(fieldModel);
expect((field as any).key).toBe(fieldModel.id);
expect(Object.keys(field)).not.toContain('key');
});
it('should hide _fieldModel property (non-enumerable)', () => {
const field = toField(fieldModel);
expect((field as any)._fieldModel).toBe(fieldModel);
expect(Object.keys(field)).not.toContain('_fieldModel');
});
it('should preserve reactivity through getters', () => {
const field = toField(fieldModel);
expect(field.name).toBe('username');
expect(field.value).toBeUndefined();
fieldModel.value = 'NewValue';
expect(field.value).toBe('NewValue');
});
});
describe('toFieldState', () => {
let formModel: FormModel;
let fieldModel: FieldModel;
beforeEach(() => {
formModel = new FormModel();
formModel.init({});
fieldModel = formModel.createField('username') as FieldModel;
});
it('should convert FieldModelState to FieldState', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState).toBeDefined();
expect(fieldState.isTouched).toBe(false);
expect(fieldState.isDirty).toBe(false);
expect(fieldState.invalid).toBe(false);
expect(fieldState.isValidating).toBe(false);
});
it('should reflect isTouched state', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState.isTouched).toBe(false);
fieldModel.state.isTouched = true;
expect(fieldState.isTouched).toBe(true);
});
it('should reflect isDirty state', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState.isDirty).toBe(false);
// Manually set dirty state
fieldModel.state.isDirty = true;
expect(fieldState.isDirty).toBe(true);
});
it('should reflect invalid state', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState.invalid).toBe(false);
fieldModel.state.invalid = true;
expect(fieldState.invalid).toBe(true);
});
it('should reflect isValidating state', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState.isValidating).toBe(false);
fieldModel.state.isValidating = true;
expect(fieldState.isValidating).toBe(true);
});
it('should return errors as flat array', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState.errors).toBeUndefined();
fieldModel.state.errors = {
validate1: ['Error 1', 'Error 2'],
validate2: ['Error 3'],
};
expect(fieldState.errors).toEqual(['Error 1', 'Error 2', 'Error 3']);
});
it('should return warnings as flat array', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState.warnings).toBeUndefined();
fieldModel.state.warnings = {
validate1: ['Warning 1', 'Warning 2'],
validate2: ['Warning 3'],
};
expect(fieldState.warnings).toEqual(['Warning 1', 'Warning 2', 'Warning 3']);
});
it('should handle empty errors object', () => {
const fieldState = toFieldState(fieldModel.state);
fieldModel.state.errors = {};
expect(fieldState.errors).toEqual([]);
});
it('should handle empty warnings object', () => {
const fieldState = toFieldState(fieldModel.state);
fieldModel.state.warnings = {};
expect(fieldState.warnings).toEqual([]);
});
it('should preserve reactivity through getters', () => {
const fieldState = toFieldState(fieldModel.state);
expect(fieldState.isTouched).toBe(false);
fieldModel.state.isTouched = true;
expect(fieldState.isTouched).toBe(true);
});
});
@@ -0,0 +1,316 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { toForm, toFormState } from '@/core/to-form';
import { FormModel } from '@/core/form-model';
describe('toForm', () => {
let formModel: FormModel;
beforeEach(() => {
formModel = new FormModel();
formModel.init({
initialValues: {
username: 'John',
email: 'john@example.com',
age: 30,
},
});
});
it('should convert FormModel to Form', () => {
const form = toForm(formModel);
expect(form).toBeDefined();
expect(form.initialValues).toEqual({
username: 'John',
email: 'john@example.com',
age: 30,
});
});
it('should expose initialValues from model', () => {
const form = toForm(formModel);
expect(form.initialValues).toBe(formModel.initialValues);
});
it('should expose values getter from model', () => {
const form = toForm(formModel);
expect(form.values).toEqual({
username: 'John',
email: 'john@example.com',
age: 30,
});
expect(form.values).toEqual(formModel.values);
});
it('should expose values setter to update model', () => {
const form = toForm(formModel);
const newValues = {
username: 'Alice',
email: 'alice@example.com',
age: 25,
};
form.values = newValues;
expect(formModel.values).toEqual(newValues);
expect(form.values).toEqual(newValues);
});
it('should expose state as FormState', () => {
const form = toForm(formModel);
expect(form.state).toBeDefined();
expect(form.state.isTouched).toBe(false);
expect(form.state.isDirty).toBe(false);
expect(form.state.invalid).toBe(false);
expect(form.state.isValidating).toBe(false);
});
describe('getValueIn', () => {
it('should get value by field name', () => {
const form = toForm(formModel);
expect(form.getValueIn('username')).toBe('John');
expect(form.getValueIn('email')).toBe('john@example.com');
expect(form.getValueIn('age')).toBe(30);
});
it('should get nested value by path', () => {
formModel.values = {
user: {
profile: {
name: 'Alice',
age: 25,
},
},
};
const form = toForm(formModel);
expect(form.getValueIn('user.profile.name')).toBe('Alice');
expect(form.getValueIn('user.profile.age')).toBe(25);
});
it('should get array value by index', () => {
formModel.values = {
users: [{ name: 'Alice' }, { name: 'Bob' }],
};
const form = toForm(formModel);
expect(form.getValueIn('users.0.name')).toBe('Alice');
expect(form.getValueIn('users.1.name')).toBe('Bob');
});
it('should return undefined for non-existent path', () => {
const form = toForm(formModel);
expect(form.getValueIn('nonexistent')).toBeUndefined();
});
});
describe('setValueIn', () => {
it('should set value by field name', () => {
const form = toForm(formModel);
form.setValueIn('username', 'Bob');
expect(formModel.values.username).toBe('Bob');
expect(form.values.username).toBe('Bob');
});
it('should set nested value by path', () => {
formModel.values = {
user: {
profile: {
name: 'Alice',
age: 25,
},
},
};
const form = toForm(formModel);
form.setValueIn('user.profile.name', 'Charlie');
expect(formModel.values.user.profile.name).toBe('Charlie');
});
it('should set array value by index', () => {
formModel.values = {
users: [{ name: 'Alice' }, { name: 'Bob' }],
};
const form = toForm(formModel);
form.setValueIn('users.0.name', 'Charlie');
expect(formModel.values.users[0].name).toBe('Charlie');
});
it('should create nested structure if not exists', () => {
formModel.values = {};
const form = toForm(formModel);
form.setValueIn('user.profile.name', 'Alice');
expect(formModel.values.user.profile.name).toBe('Alice');
});
});
describe('validate', () => {
it('should bind model validate method', () => {
const form = toForm(formModel);
expect(form.validate).toBeDefined();
expect(typeof form.validate).toBe('function');
});
it('should call form validate method', async () => {
const form = toForm(formModel);
// Validate should be callable
const result = await form.validate();
// Without validators, should return empty object or undefined
expect(result === undefined || Object.keys(result || {}).length === 0).toBe(true);
});
});
it('should hide _formModel property (non-enumerable)', () => {
const form = toForm(formModel);
expect((form as any)._formModel).toBe(formModel);
expect(Object.keys(form)).not.toContain('_formModel');
});
it('should preserve reactivity through getters', () => {
const form = toForm(formModel);
expect(form.values.username).toBe('John');
formModel.values = { username: 'Alice' };
expect(form.values.username).toBe('Alice');
});
it('should work with empty initialValues', () => {
const emptyFormModel = new FormModel();
emptyFormModel.init({});
const form = toForm(emptyFormModel);
expect(form.initialValues).toBeUndefined();
expect(form.values).toBeUndefined();
});
});
describe('toFormState', () => {
let formModel: FormModel;
beforeEach(() => {
formModel = new FormModel();
formModel.init({
initialValues: {
username: 'John',
email: 'john@example.com',
},
});
});
it('should convert FormModelState to FormState', () => {
const formState = toFormState(formModel.state);
expect(formState).toBeDefined();
expect(formState.isTouched).toBe(false);
expect(formState.isDirty).toBe(false);
expect(formState.invalid).toBe(false);
expect(formState.isValidating).toBe(false);
});
it('should reflect isTouched state', () => {
const formState = toFormState(formModel.state);
expect(formState.isTouched).toBe(false);
formModel.state.isTouched = true;
expect(formState.isTouched).toBe(true);
});
it('should reflect isDirty state', () => {
const formState = toFormState(formModel.state);
expect(formState.isDirty).toBe(false);
// Manually set dirty state
formModel.state.isDirty = true;
expect(formState.isDirty).toBe(true);
});
it('should reflect invalid state', () => {
const formState = toFormState(formModel.state);
expect(formState.invalid).toBe(false);
formModel.state.invalid = true;
expect(formState.invalid).toBe(true);
});
it('should reflect isValidating state', () => {
const formState = toFormState(formModel.state);
expect(formState.isValidating).toBe(false);
formModel.state.isValidating = true;
expect(formState.isValidating).toBe(true);
});
it('should expose errors from model state', () => {
const formState = toFormState(formModel.state);
expect(formState.errors).toBeUndefined();
formModel.state.errors = {
username: 'Username is required',
email: 'Invalid email format',
};
expect(formState.errors).toEqual({
username: 'Username is required',
email: 'Invalid email format',
});
});
it('should expose warnings from model state', () => {
const formState = toFormState(formModel.state);
expect(formState.warnings).toBeUndefined();
formModel.state.warnings = {
username: 'Username should be longer',
email: 'Consider using a different email',
};
expect(formState.warnings).toEqual({
username: 'Username should be longer',
email: 'Consider using a different email',
});
});
it('should preserve reactivity through getters', () => {
const formState = toFormState(formModel.state);
expect(formState.isTouched).toBe(false);
formModel.state.isTouched = true;
expect(formState.isTouched).toBe(true);
});
});
@@ -0,0 +1,204 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, it } from 'vitest';
import { Errors } from '@/types';
import { FieldEventUtils, mergeFeedbacks } from '@/core/utils';
describe('core/utils', () => {
describe('mergeFeedbacks', () => {
it('should merge when some key in source is empty array', () => {
const origin = {
a: ['error'],
b: ['error'],
} as unknown as Errors;
const source = {
a: [],
} as unknown as Errors;
const result = mergeFeedbacks(origin, source);
expect(result).toEqual({
a: [],
b: ['error'],
});
});
it('should merge when some key in source is undefined', () => {
const origin = {
a: ['error'],
b: ['error'],
} as unknown as Errors;
const source = {
a: undefined,
} as unknown as Errors;
const result = mergeFeedbacks(origin, source);
expect(result).toEqual({
a: undefined,
b: ['error'],
});
});
});
describe('FieldEventUtils.shouldTriggerFieldChangeEvent', () => {
it('array append: should not trigger for all array child or grand child', () => {
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-append',
indexes: [0],
},
},
'arr.0',
),
).toBe(false);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-append',
indexes: [0],
},
},
'arr.0.x',
),
).toBe(false);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-append',
indexes: [0],
},
},
'arr',
),
).toBe(true);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'p.arr',
options: {
action: 'array-append',
indexes: [0],
},
},
'p',
),
).toBe(true);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: '',
options: {
action: 'array-append',
indexes: [0],
},
},
'0',
),
).toBe(false);
});
it('array splice: should not trigger for array child or grand child only when index < first spliced index', () => {
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-splice',
indexes: [0],
},
},
'arr.0',
),
).toBe(true);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-splice',
indexes: [0],
},
},
'arr.1',
),
).toBe(true);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-splice',
indexes: [1],
},
},
'arr.0',
),
).toBe(false);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-splice',
indexes: [1, 2],
},
},
'arr.1',
),
).toBe(true);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-splice',
indexes: [4, 5],
},
},
'arr.1',
),
).toBe(false);
expect(
FieldEventUtils.shouldTriggerFieldChangeEvent(
{
values: {},
prevValues: {},
name: 'arr',
options: {
action: 'array-splice',
indexes: [],
},
},
'arr.1',
),
).toBe(true);
});
});
});
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { describe, expect, it } from 'vitest';
import { hasError } from '../src/utils/validate';
import { FeedbackLevel, FieldError } from '../src/types';
describe('utils/validate', () => {
describe('hasError', () => {
it('should return false when errors is empty', () => {
expect(hasError({ xxx: [] })).toBe(false);
expect(hasError({ xxx: undefined })).toBe(false);
expect(hasError({})).toBe(false);
expect(hasError({ aaa: [], bbb: [] })).toBe(false);
expect(hasError({ aaa: undefined, bbb: [] })).toBe(false);
});
it('should return true when errors is not empty', () => {
const mockError: FieldError = { name: 'xxx', level: FeedbackLevel.Error, message: 'err' };
expect(hasError({ xxx: [mockError] })).toBe(true);
expect(hasError({ aaa: [mockError], bbb: [mockError] })).toBe(true);
expect(hasError({ aaa: undefined, bbb: [mockError] })).toBe(true);
});
});
});
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const { defineFlatConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineFlatConfig({
preset: 'web',
packageRoot: __dirname,
});
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@flowgram.ai/form",
"version": "0.1.8",
"description": "form",
"keywords": [
"flow",
"engine"
],
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
},
"main": "./dist/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "npm run build:fast -- --dts-resolve",
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
"build:watch": "npm run build:fast -- --dts-resolve",
"clean": "rimraf dist",
"test": "vitest run",
"test:cov": "vitest run --coverage",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"@flowgram.ai/reactive": "workspace:*",
"@flowgram.ai/utils": "workspace:*",
"fast-equals": "^2.0.0",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.9"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"@testing-library/react": "^12",
"@types/lodash-es": "^4.17.12",
"@types/react": "^18",
"@types/react-dom": "^18",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.0.0",
"tsup": "^8.0.1",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FieldModelState } from './types/field';
import { FormModelState } from './types';
export const DEFAULT_FIELD_STATE: FieldModelState = {
invalid: false,
isDirty: false,
isTouched: false,
isValidating: false,
};
export const DEFAULT_FORM_STATE: FormModelState = {
invalid: false,
isDirty: false,
isTouched: false,
isValidating: false,
};
export function createFormModelState(initialState?: Partial<FormModelState>) {
if (!initialState) {
return { ...DEFAULT_FORM_STATE };
}
return { ...DEFAULT_FORM_STATE, ...initialState };
}
export function createFieldModelState(initialState?: Partial<FieldModelState>): FieldModelState {
if (!initialState) {
return { ...DEFAULT_FIELD_STATE };
}
return { ...DEFAULT_FIELD_STATE, ...initialState };
}
@@ -0,0 +1,58 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CreateFormReturn, FormOptions } from '../types/form';
import { Field, FieldArray, FieldName, FieldValue } from '../types';
import { toForm } from './to-form';
import { toFieldArray } from './to-field-array';
import { toField } from './to-field';
import { FormModel } from './form-model';
import { FieldModel } from './field-model';
import { FieldArrayModel } from './field-array-model';
// export interface CreateFormOptions<TValues = any> extends FormOptions<TValues> {
// parentContainer?: interfaces.Container;
// }
export type CreateFormOptions<T = any> = FormOptions<T> & {
/**
* 为 true 时,createForm 不会对form 初始化, 用户需要手动调用 control.init()
* 该配置主要为了解决,用户需要去监听一些form 的初始化事件,那么他需要再配置完监听后再初始化。
* 该配置默认为 false
**/
disableAutoInit?: boolean;
};
export function createForm<TValues>(
options?: CreateFormOptions<TValues>
): CreateFormReturn<TValues> {
const { disableAutoInit = false, ...formOptions } = options || {};
const formModel = new FormModel();
if (!disableAutoInit) {
formModel.init(formOptions || {});
}
return {
form: toForm(formModel),
control: {
_formModel: formModel,
getField: <
TFieldValue = FieldValue,
TFieldModel extends Field<TFieldValue> | FieldArray<TFieldValue> = Field
>(
name: FieldName
) => {
const fieldModel = formModel.getField(name);
if (fieldModel) {
return fieldModel instanceof FieldArrayModel
? toFieldArray<TFieldValue>(fieldModel as unknown as FieldArrayModel<TFieldValue>)
: toField<TFieldValue>(fieldModel as unknown as FieldModel<TFieldValue>);
}
},
init: () => formModel.init(formOptions || {}),
},
};
}
@@ -0,0 +1,333 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Emitter } from '@flowgram.ai/utils';
import { FieldValue } from '../types';
import { Path } from './path';
import { FieldModel } from './field-model';
export class FieldArrayModel<TValue = FieldValue> extends FieldModel<Array<TValue>> {
protected onAppendEmitter = new Emitter<{
index: number;
value: TValue | undefined;
arrayValue: Array<TValue>;
}>();
readonly onAppend = this.onAppendEmitter.event;
protected onDeleteEmitter = new Emitter<{
arrayValue: Array<TValue> | undefined;
index: number;
}>();
readonly onDelete = this.onDeleteEmitter.event;
get children() {
const fields: FieldModel[] = [];
this.form.fieldMap.forEach((field, name: string) => {
if (this.path.isChild(name)) {
fields.push(field);
}
});
// 按 index 排序
return fields.sort((f1, f2) => {
const p1 = f1.path.value;
const p2 = f2.path.value;
const i1 = parseInt(p1[p1.length - 1]);
const i2 = parseInt(p2[p2.length - 1]);
return i1 - i2;
});
}
map<T>(cb: (f: FieldModel, index: number, arr: FieldModel[]) => T) {
const fields = (this.value || []).map((v: TValue, i: number) => {
const pathString = this.path.concat(i).toString();
let field = this.form.getField(pathString);
if (!field) {
field = this.form.createField(pathString);
}
return field;
});
return fields.map(cb);
}
append(value?: TValue) {
const curLength = this.value?.length || 0;
const newElemPath = this.path.concat(curLength).toString();
const newElemField = this.form.createField(newElemPath);
const newArrayValue = this.value ? [...this.value, value] : [value];
const prevFormValues = this.form.values;
// 设置新的数组值并触发事件
this.form.store.setIn(new Path(this.name), newArrayValue);
this.form.fireOnFormValuesChange({
values: this.form.values,
prevValues: prevFormValues,
name: this.name,
options: {
action: 'array-append',
indexes: [curLength],
},
});
// 触发新元素的初始值变更
this.form.fireOnFormValuesInit({
values: this.form.values,
prevValues: prevFormValues,
name: newElemPath,
});
this.onAppendEmitter.fire({
value,
arrayValue: this.value as Array<TValue>,
index: this.value!.length - 1,
});
return newElemField;
}
/**
* Delete the element in given index and delete the corresponding FieldModel as well
* @param index
*/
delete(index: number) {
// const field = this.form.getField(name);
// if (!field) {
// throw new Error(
// `[Form] Error in FieldArrayModel.delete: delete failed, no field found for name ${name}`,
// );
// }
// const index = field.path.getArrayIndex(this.path);
this._splice(index, 1);
this.onDeleteEmitter.fire({ arrayValue: this.value, index });
}
_splice(start: number, deleteCount = 1) {
if (start < 0 || deleteCount < 0) {
throw new Error(
`[Form] Error in FieldArrayModel.splice: Invalid Params, start and deleteCount should > 0`
);
}
if (!this.value || this.value.length === 0 || deleteCount > this.value.length) {
throw new Error(
`[Form] Error in FieldArrayModel.splice: delete count exceeds array length, tried to delete ${deleteCount} elements, but array length is ${
this.value?.length || 0
}`
);
}
const oldFormValues = this.form.values;
const tempValue = [...this.value];
tempValue.splice(start, deleteCount);
// 设置数组值并触发事件
this.form.store.setIn(new Path(this.name), tempValue);
this.form.fireOnFormValuesChange({
values: this.form.values,
prevValues: oldFormValues,
name: this.name,
options: {
action: 'array-splice',
indexes: Array.from({ length: deleteCount }, (_, i) => i + start),
},
});
const children = this.children;
// 如果要删除的元素都在数组末端, 直接删除
if (start + deleteCount >= children.length) {
for (let i = start; i < children.length; i++) {
this.form.disposeField(children[i].name);
}
}
const toDispose: FieldModel[] = [];
const newFieldMap = new Map<string, FieldModel>(this.form.fieldMap);
const recursiveHandleChildField = (field: FieldModel, index: number) => {
if (field.children?.length) {
field.children.forEach((cField) => {
recursiveHandleChildField(cField, index);
});
}
// start 以前的项不变
if (index < start) {
newFieldMap.set(field.name, field);
}
// 要删除的项, 放入toDispose
else if (index < start + deleteCount) {
toDispose.push(field);
}
// 剩余的项 index 向前移动 {deleteCount} 位, 并触发变更事件
else {
const originName = field.name;
const targetName = field.path
.replaceParent(this.path.concat(index), this.path.concat(index - deleteCount))
.toString();
newFieldMap.set(targetName, field);
if (!field.children.length) {
field.updateNameForLeafState(targetName);
field.bubbleState();
}
field.name = targetName;
// 最后 {deleteCount} 项,需要fire 被变更为undefined 并从 newMap 中删除
if (index > children.length - deleteCount - 1) {
newFieldMap.delete(originName);
}
}
};
// 对数组所有子项做删除或 index 移动操作
children.map((field, index) => {
recursiveHandleChildField(field, index);
});
toDispose.forEach((f) => {
f.dispose();
});
this.form.fieldMap = newFieldMap;
this.form.alignStateWithFieldMap();
}
swap(from: number, to: number) {
if (!this.value) {
return;
}
if (from < 0 || to < 0 || from > this.value.length - 1 || to > this.value.length - 1) {
throw new Error(
`[Form]: FieldArrayModel.swap Error: invalid params 'form' and 'to', form=${from} to=${to}. expect the value between 0 to ${
length - 1
}`
);
}
const oldFormValues = this.form.values;
const tempValue = [...this.value];
const fromValue = tempValue[from];
const toValue = tempValue[to];
tempValue[to] = fromValue;
tempValue[from] = toValue;
this.form.store.setIn(this.path, tempValue);
this.form.fireOnFormValuesChange({
values: this.form.values,
prevValues: oldFormValues,
name: this.name,
options: {
action: 'array-swap',
indexes: [from, to],
},
});
// swap related FieldModels
const newFieldMap = new Map<string, FieldModel>(this.form.fieldMap);
const fromFields = this.findAllFieldsAt(from);
const toFields = this.findAllFieldsAt(to);
const fromRootPath = this.getPathAt(from);
const toRootPath = this.getPathAt(to);
const leafFieldsModified: FieldModel[] = [];
fromFields.forEach((f) => {
const newName = f.path.replaceParent(fromRootPath, toRootPath).toString();
f.name = newName;
if (!f.children.length) {
f.updateNameForLeafState(newName);
leafFieldsModified.push(f);
}
newFieldMap.set(newName, f);
});
toFields.forEach((f) => {
const newName = f.path.replaceParent(toRootPath, fromRootPath).toString();
f.name = newName;
if (!f.children.length) {
f.updateNameForLeafState(newName);
}
newFieldMap.set(newName, f);
leafFieldsModified.push(f);
});
this.form.fieldMap = newFieldMap;
leafFieldsModified.forEach((f) => f.bubbleState());
this.form.alignStateWithFieldMap();
}
move(from: number, to: number) {
if (!this.value) {
return;
}
if (from < 0 || to < 0 || from > this.value.length - 1 || to > this.value.length - 1) {
throw new Error(
`[Form]: FieldArrayModel.move Error: invalid params 'form' and 'to', form=${from} to=${to}. expect the value between 0 to ${
length - 1
}`
);
}
const tempValue = [...this.value];
const fromValue = tempValue[from];
tempValue.splice(from, 1);
tempValue.splice(to, 0, fromValue);
this.form.setValueIn(this.name, tempValue);
// todo(fix): should move fields in order to make sure fields' state is also moved
}
protected insertAt(index: number, value: TValue) {
if (!this.value) {
return;
}
if (index < 0 || index > this.value.length) {
throw new Error(`[Form]: FieldArrayModel.insertAt Error: index exceeds array boundary`);
}
const tempValue = [...this.value];
tempValue.splice(index, 0, value);
this.form.setValueIn(this.name, tempValue);
// todo: should move field in order to make sure field state is also moved
}
/**
* get element path at given index
* @param index
* @protected
*/
protected getPathAt(index: number) {
return this.path.concat(index);
}
/**
* find all fields including child and grandchild fields at given index.
* @param index
* @protected
*/
protected findAllFieldsAt(index: number) {
const rootPath = this.getPathAt(index);
const rootPathString = rootPath.toString();
const res: FieldModel[] = this.form.fieldMap.get(rootPathString)
? [this.form.fieldMap.get(rootPathString)!]
: [];
this.form.fieldMap.forEach((field, fieldName) => {
if (rootPath.isChildOrGrandChild(fieldName)) {
res.push(field);
}
});
return res;
}
}
@@ -0,0 +1,396 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import { get, groupBy, some } from 'lodash-es';
import { Disposable, DisposableCollection, Emitter } from '@flowgram.ai/utils';
import { ReactiveState } from '@flowgram.ai/reactive';
import { toFeedback } from '../utils/validate';
import { FieldModelState, FieldName, FieldValue, Ref } from '../types/field';
import {
Errors,
FeedbackLevel,
FieldError,
FieldWarning,
Validate,
ValidateTrigger,
Warnings,
} from '../types';
import { createFieldModelState, DEFAULT_FIELD_STATE } from '../constants';
import {
clearFeedbacks,
FieldEventUtils,
mergeFeedbacks,
shouldValidate,
updateFeedbacksName,
} from './utils';
import { Path } from './path';
import { FormModel } from './form-model';
interface OnValueChangePayload<TValue> {
value: TValue | undefined;
prevValue: TValue | undefined;
formValues: any;
prevFormValues: any;
}
export class FieldModel<TValue extends FieldValue = FieldValue> implements Disposable {
readonly onValueChangeEmitter = new Emitter<OnValueChangePayload<TValue>>();
readonly form: FormModel;
readonly id: string;
readonly onValueChange = this.onValueChangeEmitter.event;
protected toDispose = new DisposableCollection();
protected _ref?: Ref;
protected _path: Path;
protected _state: ReactiveState<FieldModelState> = new ReactiveState<FieldModelState>(
createFieldModelState()
);
/**
* @deprecated
* 原用于直接给field 设置validate 逻辑,现将该逻辑放到form._options.validate 中设置,该字段暂时弃用
*/
originalValidate?: Validate;
protected _renderCount: number = 0;
constructor(path: Path, form: FormModel) {
this._path = path;
this.form = form;
this.id = nanoid();
const changeDisposable = this.form.onFormValuesChange((payload) => {
const { values, prevValues } = payload;
if (FieldEventUtils.shouldTriggerFieldChangeEvent(payload, this.name)) {
this.onValueChangeEmitter.fire({
value: get(values, this.name),
prevValue: get(prevValues, this.name),
formValues: values,
prevFormValues: prevValues,
});
if (
shouldValidate(ValidateTrigger.onChange, this.form.validationTrigger) &&
FieldEventUtils.shouldTriggerFieldValidateWhenChange(payload, this.name)
) {
this.validate();
}
}
});
this.toDispose.push(changeDisposable);
// if (shouldValidate(ValidateTrigger.onChange, this.form.validationTrigger)) {
// const validateDisposable = this.form.onFormValuesChange(({ name, values, prevValues }) => {
// /**
// * Field 值变更时,所有 ancestor 以及所有child 和 grand child 的校验都要触发
// */
// if (Glob.isMatchOrParent(this.name, name) || Glob.isMatchOrParent(name, this.name)) {
// this.validate();
// }
// });
// this.toDispose.push(validateDisposable);
// }
this.toDispose.push(this.onValueChangeEmitter);
this.initState();
}
protected _mount: boolean = false;
get renderCount() {
return this._renderCount;
}
set renderCount(n: number) {
this._renderCount = n;
}
private initState() {
const initialErrors = get(this.form.state.errors, this.name);
const initialWarnings = get(this.form.state.warnings, this.name);
if (initialErrors) {
this.state.errors = {
[this.name]: initialErrors,
};
}
if (initialWarnings) {
this.state.warnings = {
[this.name]: initialWarnings,
};
}
}
get path() {
return this._path;
}
get name() {
return this._path.toString();
}
set name(name: FieldName) {
this._path = new Path(name);
}
get ref() {
return this._ref;
}
set ref(ref: Ref | undefined) {
this._ref = ref;
}
get state() {
return this._state.value;
}
get reactiveState() {
return this._state;
}
get value() {
return this.form.getValueIn(this.name);
}
set value(value: TValue | undefined) {
this.form.setValueIn(this.name, value);
if (!this.state.isTouched) {
this.state.isTouched = true;
this.bubbleState();
}
}
updateNameForLeafState(newName: string) {
const { errors, warnings } = this.state;
const nameInErrors = errors ? Object.keys(errors)?.[0] : undefined;
if (nameInErrors && errors?.[nameInErrors] && nameInErrors !== newName) {
this.state.errors = {
[newName]: errors?.[nameInErrors]
? updateFeedbacksName(errors?.[nameInErrors], newName)
: errors?.[nameInErrors],
};
}
const nameInWarnings = warnings ? Object.keys(warnings)?.[0] : undefined;
if (nameInWarnings && warnings?.[nameInWarnings] && nameInWarnings !== newName) {
this.state.warnings = {
[newName]: warnings?.[nameInWarnings]
? updateFeedbacksName(warnings?.[nameInWarnings], newName)
: warnings?.[nameInWarnings],
};
}
}
// recursiveUpdateName(name: FieldName) {
// if (this.children?.length) {
// this.children.forEach(c => {
// c.recursiveUpdateName(c.path.replaceParent(this.path, new Path(name)).toString());
// });
// } else {
// this.updateNameForLeafState(name);
// this.bubbleState();
// }
// this.name = name;
// }
/**
* @deprecated
* @param validate
* @param from
*/
updateValidate(validate: Validate | undefined, from?: 'ui') {
if (from === 'ui') {
// todo(heyuan):暂时逻辑: 只在没有全局配置校验时来自ui 的validate 才生效。 后续需要支持多validate合并, ui 和全局的都需要生效
if (!this.originalValidate) {
this.originalValidate = validate;
}
} else {
this.originalValidate = validate;
}
}
bubbleState() {
const { errors, warnings } = this.state;
if (this.parent) {
this.parent.state.isTouched = some(
this.parent.children.map((c) => c.state.isTouched),
Boolean
);
this.parent.state.invalid = some(
this.parent.children.map((c) => c.state.invalid),
Boolean
);
this.parent.state.isDirty = some(
this.parent.children.map((c) => c.state.isDirty),
Boolean
);
this.parent.state.isValidating = some(
this.parent.children.map((c) => c.state.isValidating),
Boolean
);
this.parent.state.errors = errors
? mergeFeedbacks<Errors>(this.parent.state.errors, errors)
: clearFeedbacks(this.name, this.parent.state.errors);
this.parent.state.warnings = warnings
? mergeFeedbacks<Warnings>(this.parent.state.warnings, warnings)
: clearFeedbacks(this.name, this.parent.state.warnings);
this.parent.bubbleState();
return;
}
// parent 不存在,则更新form state
this.form.state.isTouched = some(
this.form.fields.map((f) => f.state.isTouched),
Boolean
);
this.form.state.invalid = some(
this.form.fields.map((f) => f.state.invalid),
Boolean
);
this.form.state.isDirty = some(
this.form.fields.map((f) => f.state.isDirty),
Boolean
);
this.form.state.isValidating = some(
this.form.fields.map((f) => f.state.isValidating),
Boolean
);
this.form.state.errors = errors
? mergeFeedbacks<Errors>(this.form.state.errors, errors)
: clearFeedbacks(this.name, this.form.state.errors);
this.form.state.warnings = warnings
? mergeFeedbacks<Warnings>(this.form.state.warnings, warnings)
: clearFeedbacks(this.name, this.form.state.warnings);
// console.log('>>>> bubble state: ', this.form.state.errors, this.form.state.invalid, this.form.fields.map(f => f.state.invalid))
}
clearState() {
this.state.errors = DEFAULT_FIELD_STATE.errors;
this.state.warnings = DEFAULT_FIELD_STATE.warnings;
this.state.isTouched = DEFAULT_FIELD_STATE.isTouched;
this.state.isDirty = DEFAULT_FIELD_STATE.isDirty;
this.bubbleState();
}
get children(): FieldModel[] {
const res: FieldModel[] = [];
this.form.fieldMap.forEach((field, path: string) => {
if (this.path.isChild(path)) {
res.push(field);
}
});
return res;
}
get parent(): FieldModel | undefined {
const parentPath = this.path.parent;
if (!parentPath) {
return undefined;
}
return this.form.fieldMap.get(parentPath.toString());
}
clear() {
if (!this.value) {
return;
}
this.value = undefined;
}
async validate() {
// 以下代码由于导致arr 配置的校验不触发,暂时注释,支持对父节点配置校验逻辑
// const children = this.children;
// 如果是非叶子field, 执行children的校验。暂不支持在父级上配校验器
// if (children?.length) {
// await Promise.all(this.children.map(c => c.validate()));
// return;
// }
await this.validateSelf();
}
async validateSelf() {
this.state.isValidating = true;
this.bubbleState();
const { errors, warnings } = await this._runAsyncValidate();
if (errors?.length) {
this.state.errors = groupBy(errors, 'name');
this.state.invalid = true;
} else {
this.state.errors = { [this.name]: [] };
this.state.invalid = false;
}
if (warnings?.length) {
this.state.warnings = groupBy(warnings, 'name');
} else {
this.state.warnings = { [this.name]: [] };
}
this.state.isValidating = false;
this.bubbleState();
this.form.onValidateEmitter.fire(this.form.state);
}
protected async _runAsyncValidate(): Promise<{
errors?: FieldError[];
warnings?: FieldWarning[];
}> {
let errors: FieldError[] = [];
let warnings: FieldWarning[] = [];
const results = await this.form.validateIn(this.name);
if (!results?.length) {
return {};
} else {
const feedbacks = results.map((result) => toFeedback(result, this.name)).filter(Boolean) as (
| FieldError
| FieldWarning
)[];
if (!feedbacks?.length) {
return {};
}
const groupedFeedbacks = groupBy(feedbacks, 'level');
warnings = warnings.concat((groupedFeedbacks[FeedbackLevel.Warning] as FieldWarning[]) || []);
errors = errors.concat((groupedFeedbacks[FeedbackLevel.Error] as FieldError[]) || []);
}
return { errors, warnings };
}
updateState(s: Partial<FieldModel>) {
// todo
}
dispose() {
this.children.map((c) => c.dispose());
// Do not reset state when field disposed, since it will clear errors and warnings in form model as well.
// todo: remove following line and related ut after a few weeks test online
// this.clearState();
this.toDispose.dispose();
this.form.fieldMap.delete(this.path.toString());
}
onDispose(fn: () => void) {
this.toDispose.onDispose(fn);
}
get disposed() {
return this.toDispose.disposed;
}
}
@@ -0,0 +1,362 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { flatten, get } from 'lodash-es';
import { deepEqual } from 'fast-equals';
import { Disposable, Emitter } from '@flowgram.ai/utils';
import { ReactiveState } from '@flowgram.ai/reactive';
import { feedbackToFieldErrorsOrWarnings, hasError, toFeedback } from '../utils/validate';
import { Glob } from '../utils/glob';
import { keepValidKeys } from '../utils';
import {
FormModelState,
FormOptions,
FormState,
OnFormValuesChangePayload,
OnFormValuesInitPayload,
OnFormValuesUpdatedPayload,
} from '../types/form';
import { FieldName, FieldValue } from '../types/field';
import { Errors, FeedbackLevel, FormValidateReturn, Validate, Warnings } from '../types';
import { createFormModelState } from '../constants';
import { getValidByErrors, mergeFeedbacks } from './utils';
import { Store } from './store';
import { Path } from './path';
import { FieldModel } from './field-model';
import { FieldArrayModel } from './field-array-model';
export class FormModel<TValues = any> implements Disposable {
protected _fieldMap: Map<string, FieldModel> = new Map();
readonly store = new Store();
protected _options: FormOptions = {};
protected onFieldModelCreateEmitter = new Emitter<FieldModel>();
readonly onFieldModelCreate = this.onFieldModelCreateEmitter.event;
readonly onFormValuesChangeEmitter = new Emitter<OnFormValuesChangePayload>();
readonly onFormValuesChange = this.onFormValuesChangeEmitter.event;
readonly onFormValuesInitEmitter = new Emitter<OnFormValuesInitPayload>();
readonly onFormValuesInit = this.onFormValuesInitEmitter.event;
readonly onFormValuesUpdatedEmitter = new Emitter<OnFormValuesUpdatedPayload>();
readonly onFormValuesUpdated = this.onFormValuesUpdatedEmitter.event;
readonly onValidateEmitter = new Emitter<FormModelState>();
readonly onValidate = this.onValidateEmitter.event;
protected _state: ReactiveState<FormModelState> = new ReactiveState<FormModelState>(
createFormModelState()
);
protected _initialized = false;
set fieldMap(map) {
this._fieldMap = map;
}
/**
* 表单初始值,初始化设置后不可修改
* @protected
*/
// protected _initialValues?: TValues;
get fieldMap() {
return this._fieldMap;
}
get context() {
return this._options.context;
}
get initialValues() {
return this._options.initialValues;
}
get values() {
return this.store.values;
}
set values(v) {
const prevValues = this.values;
if (deepEqual(prevValues, v)) {
return;
}
this.store.values = v;
this.fireOnFormValuesChange({
values: this.values,
prevValues,
name: '',
});
}
get validationTrigger() {
return this._options.validateTrigger;
}
get state() {
return this._state.value;
}
get reactiveState() {
return this._state;
}
get fields(): FieldModel[] {
return Array.from(this.fieldMap.values());
}
updateState(state: Partial<FormState>) {
// todo
}
get initialized() {
return this._initialized;
}
fireOnFormValuesChange(payload: OnFormValuesChangePayload) {
this.onFormValuesChangeEmitter.fire(payload);
this.onFormValuesUpdatedEmitter.fire(payload);
}
fireOnFormValuesInit(payload: OnFormValuesInitPayload) {
this.onFormValuesInitEmitter.fire(payload);
this.onFormValuesUpdatedEmitter.fire(payload);
}
init(options: FormOptions<TValues>) {
this._options = options;
if (options.initialValues) {
const prevValues = this.store.values;
this.store.values = options.initialValues;
this.fireOnFormValuesInit({
values: options.initialValues,
prevValues,
name: '',
});
}
this._initialized = true;
}
createField<TValue = FieldValue>(name: FieldName, isArray?: boolean): FieldModel<TValue> {
const path = new Path(name);
const pathString = path.toString();
if (this.fieldMap.get(pathString)) {
return this.fieldMap.get(pathString)!;
}
// const fieldValue = value || get(this.initialValues, pathString);
const field: FieldModel = isArray
? new FieldArrayModel(path, this)
: new FieldModel(path, this);
this.fieldMap.set(pathString, field);
field.onDispose(() => {
this.fieldMap.delete(pathString);
});
this.onFieldModelCreateEmitter.fire(field);
return field;
}
createFieldArray<TValue = FieldValue>(
name: FieldName,
value?: Array<TValue>
): FieldArrayModel<TValue> {
return this.createField<Array<TValue>>(name, true) as FieldArrayModel<TValue>;
}
/**
* 销毁Field 模型和子模型,但不会删除field的值
* @param name
*/
disposeField(name: string) {
const field = this.fieldMap.get(name);
if (field) {
field.dispose();
}
}
/**
* 删除field, 会删除值和 Field 模型, 以及对应的子模型
* @param name
*/
deleteField(name: string) {
const field = this.fieldMap.get(name);
if (field) {
// 销毁值
field.clear();
// 销毁模型
field.dispose();
}
}
getField<TFieldModel extends FieldModel | FieldArrayModel = FieldModel>(
name: FieldName
): TFieldModel | undefined {
return this.fieldMap.get(new Path(name).toString()) as TFieldModel | undefined;
}
getValueIn<TValue>(name: FieldName): TValue {
return this.store.getIn<TValue>(new Path(name));
}
setValueIn<TValue>(name: FieldName, value: TValue): void {
const prevValues = this.values;
this.store.setIn(new Path(name), value);
this.fireOnFormValuesChange({
values: this.values,
prevValues,
name,
});
}
setInitValueIn<TValue = any>(name: FieldName, value: TValue): void {
const path = new Path(name);
const prevValue = this.store.getIn(path);
if (prevValue === undefined) {
const prevValues = this.values;
this.store.setIn(new Path(name), value);
this.fireOnFormValuesInit({
values: this.values,
prevValues,
name,
});
}
}
validateDisabled = false;
clearValueIn(name: FieldName) {
this.setValueIn(name, undefined);
}
async validateIn(name: FieldName) {
if (this.validateDisabled) return [];
const validateOptions = this.getValidateOptions();
if (!validateOptions) {
return;
}
const validateKeys = Object.keys(validateOptions).filter((pattern) =>
Glob.isMatch(pattern, name)
);
const validatePromises = validateKeys.map(async (validateKey) => {
const validate = validateOptions![validateKey];
return validate({
value: this.getValueIn(name),
formValues: this.values,
context: this.context,
name,
});
});
return Promise.all(validatePromises);
}
protected getValidateOptions(): Record<string, Validate> | undefined {
const validate = this._options.validate;
if (typeof validate === 'function') {
return validate(this.values, this.context);
}
return validate;
}
async validate(): Promise<FormValidateReturn> {
if (this.validateDisabled) return [];
const validateOptions = this.getValidateOptions();
if (!validateOptions) {
return [];
}
const feedbacksArrPromises = Object.keys(validateOptions).map(async (nameRule) => {
const validate = validateOptions![nameRule];
const values = this.values;
const paths = Glob.findMatchPathsWithEmptyValue(values, nameRule);
return Promise.all(
paths.map(async (path) => {
const result = await validate({
value: get(values, path),
formValues: values,
context: this.context,
name: path,
});
const feedback = toFeedback(result, path);
const field = this.getField(path);
const errors = feedbackToFieldErrorsOrWarnings<Errors>(
path,
feedback?.level === FeedbackLevel.Error ? feedback : undefined
);
const warnings = feedbackToFieldErrorsOrWarnings<Warnings>(
path,
feedback?.level === FeedbackLevel.Warning ? feedback : undefined
);
if (field) {
field.state.errors = errors;
field.state.warnings = warnings;
field.state.invalid = hasError(errors);
field.bubbleState();
}
// 无论是否存在 field 都要保证 form 的state 被更新
this.state.errors = mergeFeedbacks(this.state.errors, errors);
this.state.warnings = mergeFeedbacks(this.state.warnings, warnings);
this.state.invalid = !getValidByErrors(this.state.errors);
return feedback;
})
);
});
this.state.isValidating = true;
const feedbacksArr = await Promise.all(feedbacksArrPromises);
this.state.isValidating = false;
this.onValidateEmitter.fire(this.state);
return flatten(feedbacksArr).filter(Boolean) as FormValidateReturn;
}
alignStateWithFieldMap() {
const keys = Array.from(this.fieldMap.keys());
if (this.state.errors) {
this.state.errors = keepValidKeys(this.state.errors, keys);
}
if (this.state.warnings) {
this.state.warnings = keepValidKeys(this.state.warnings, keys);
}
this.fieldMap.forEach((f) => {
if (f.state.errors) {
f.state.errors = keepValidKeys(f.state.errors, keys);
}
if (f.state.warnings) {
f.state.warnings = keepValidKeys(f.state.warnings, keys);
}
});
}
dispose() {
this.fieldMap.forEach((f) => f.dispose());
this.store.dispose();
this._initialized = false;
}
}
@@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { FormModel } from './form-model';
export { createForm, type CreateFormOptions } from './create-form';
export { FieldModel } from './field-model';
export { FieldArrayModel } from './field-array-model';
export { toField, toFieldState } from './to-field';
export { toFieldArray } from './to-field-array';
export { toForm, toFormState } from './to-form';
export { Path } from './path';
+125
View File
@@ -0,0 +1,125 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { toPath } from 'lodash-es';
export class Path {
protected _path: string[] = [];
constructor(path: string | string[]) {
this._path = toPath(path);
}
get parent(): Path | undefined {
if (this._path.length < 2) {
return undefined;
}
return new Path(this._path.slice(0, -1));
}
toString(): string {
return this._path.join('.');
}
get value(): string[] {
return this._path;
}
/**
* 仅计直系child
* @param path
*/
isChild(path: string) {
const target = new Path(path).value;
const self = this.value;
if (target.length - self.length !== 1) {
return false;
}
for (let i = 0; i < self.length; i++) {
if (target[i] !== self[i]) {
return false;
}
}
return true;
}
/**
* 比较两个数组path大小
* 返回小于0则path1<path2, 大于0 则path1>path2, 等于0则相等
* @param path1
* @param path2
*/
static compareArrayPath(path1: Path, path2: Path): number | void {
let i = 0;
while (path1.value[i] && path2.value[i]) {
const index1 = parseInt(path1.value[i]);
const index2 = parseInt(path2.value[i]);
if (!isNaN(index1) && !isNaN(index2)) {
return index1 - index2;
} else if (path1.value[i] !== path2.value[i]) {
throw new Error(
`[Form] Path.compareArrayPath invalid input Error: two path should refers to the same array, but got path1: ${path1.toString()}, path2: ${path2.toString()}`
);
}
i++;
}
throw new Error(
`[Form] Path.compareArrayPath invalid input Error: got path1: ${path1.toString()}, path2: ${path2.toString()}`
);
}
isChildOrGrandChild(path: string) {
const target = new Path(path).value;
const self = this.value;
if (target.length - self.length < 1) {
return false;
}
for (let i = 0; i < self.length; i++) {
if (target[i] !== self[i]) {
return false;
}
}
return true;
}
getArrayIndex(parent: Path) {
return parseInt(this._path[parent.value.length]);
}
concat(name: number | string) {
if (typeof name === 'string' || typeof name === 'number') {
return new Path(this._path.concat(new Path(name.toString())._path));
}
throw new Error(
`[Form] Error in Path.concat: invalid param type, require number or string, but got ${typeof name}`
);
}
replaceParent(parent: Path, newParent: Path) {
if (parent.value.length > this.value.length) {
throw new Error(
`[Form] Error in Path.replaceParent: invalid parent param: ${parent}, parent length should not greater than current length.`
);
}
const rest = [];
for (let i = 0; i < this.value.length; i++) {
if (i < parent.value.length && parent.value[i] !== this.value[i]) {
throw new Error(
`[Form] Error in Path.replaceParent: invalid parent param: '${parent}' is not a parent of '${this.toString()}'`
);
}
if (i >= parent.value.length) {
rest.push(this.value[i]);
}
}
return new Path(newParent.value.concat(rest));
}
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { get, clone, cloneDeep } from 'lodash-es';
import { shallowSetIn } from '../utils';
import { FieldValue } from '../types/field';
import { Path } from './path';
export class Store<TValues = FieldValue> {
protected _values: TValues;
get values(): TValues {
return clone(this._values);
}
set values(v) {
this._values = cloneDeep(v);
}
setIn<TValue = FieldValue>(path: Path, value: TValue): void {
// shallow clone set
this._values = shallowSetIn(this._values || {}, path.toString(), value);
}
getIn<TValue = FieldValue>(path: Path): TValue {
return get(this.values, path.value);
}
dispose() {}
}
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field, FieldArray } from '../types/field';
import { toField } from './to-field';
import { FieldArrayModel } from './field-array-model';
export function toFieldArray<TValue>(model: FieldArrayModel<TValue>): FieldArray<TValue> {
const res: FieldArray<TValue> = {
get key() {
return model.id;
},
get name() {
return model.path.toString();
},
get value() {
return model.value;
},
onChange: (value) => {
model.value = value;
},
map: <T = any>(cb: (f: Field<TValue>, index: number) => T) =>
model.map<T>((f, index) => cb(toField(f), index)),
append: (value) => toField<TValue>(model.append(value)),
/**
* @deprecated: use remove instead
* @param index
*/
delete: (index: number) => model.delete(index),
remove: (index: number) => model.delete(index),
swap: (from: number, to: number) => model.swap(from, to),
move: (from: number, to: number) => model.move(from, to),
} as FieldArray<TValue>;
// Object.defineProperty(res, 'validate', {
// enumerable: false,
// get() {
// return model.validate.bind(model);
// },
// });
// 隐藏属性
Object.defineProperty(res, '_fieldModel', {
enumerable: false,
get() {
return model;
},
});
return res;
}
@@ -0,0 +1,84 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
import { isCheckBoxEvent, isReactChangeEvent } from '../utils';
import { Field, FieldModelState } from '../types/field';
import { ValidateTrigger } from '../types';
import { shouldValidate } from './utils';
import { FieldModel } from './field-model';
export function toField<TValue>(model: FieldModel): Field<TValue> {
const res: Field<TValue> = {
get name() {
return model.name;
},
get value() {
return model.value;
},
onChange: (e: unknown) => {
if (isReactChangeEvent(e)) {
model.value = isCheckBoxEvent(e)
? e.target.checked
: (e as React.ChangeEvent<HTMLInputElement>).target.value;
} else {
model.value = e;
}
},
onBlur() {
if (shouldValidate(ValidateTrigger.onBlur, model.form.validationTrigger)) {
model.validate();
}
},
onFocus() {
model.state.isTouched = true;
},
} as Field<TValue>;
Object.defineProperty(res, 'key', {
enumerable: false,
get() {
return model.id;
},
});
Object.defineProperty(res, '_fieldModel', {
enumerable: false,
get() {
return model;
},
});
return res;
}
export function toFieldState(modelState: FieldModelState) {
return {
get isTouched() {
return modelState.isTouched;
},
get invalid() {
return modelState.invalid;
},
get isDirty() {
return modelState.isDirty;
},
get isValidating() {
return modelState.isValidating;
},
get errors() {
if (modelState.errors) {
return Object.values(modelState.errors).reduce((acc, arr) => acc.concat(arr), []);
}
return;
},
get warnings() {
if (modelState.warnings) {
return Object.values(modelState.warnings).reduce((acc, arr) => acc.concat(arr), []);
}
return;
},
};
}
@@ -0,0 +1,64 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Form, FormModelState, FormState } from '../types/form';
import { FieldName, FieldValue } from '../types/field';
import { FormModel } from './form-model';
export function toForm<TValue>(model: FormModel): Form<TValue> {
const res = {
initialValues: model.initialValues,
get values() {
return model.values;
},
set values(v) {
model.values = v;
},
state: toFormState(model.state),
getValueIn: <TValue = FieldValue>(name: FieldName) => model.getValueIn(name),
setValueIn: <TValue>(name: FieldName, value: TValue) => model.setValueIn(name, value),
validate: model.validate.bind(model),
};
Object.defineProperty(res, '_formModel', {
enumerable: false,
get() {
return model;
},
});
return res as Form<TValue>;
}
export function toFormState(modelState: FormModelState): FormState {
return {
get isTouched() {
return modelState.isTouched;
},
get invalid() {
return modelState.invalid;
},
get isDirty() {
return modelState.isDirty;
},
get isValidating() {
return modelState.isValidating;
},
// get dirtyFields() {
// return modelState.dirtyFields;
// },
// get isLoading() {
// return modelState.isLoading;
// },
// get touchedFields() {
// return modelState.touchedFields;
// },
get errors() {
return modelState.errors;
},
get warnings() {
return modelState.warnings;
},
};
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { isEmpty, isEqual } from 'lodash-es';
import { Glob } from '../utils';
import { Errors, Feedback, OnFormValuesChangePayload, ValidateTrigger, Warnings } from '../types';
import { Path } from './path';
export function updateFeedbacksName(feedbacks: Feedback<any>[], name: string) {
return (feedbacks || []).map((f) => ({
...f,
name,
}));
}
export function mergeFeedbacks<T extends Errors | Warnings>(origin?: T, source?: T) {
if (!source) {
return origin;
}
if (!origin) {
return { ...source };
}
const changed = Object.keys(source).some(
(sourceKey) => !isEqual(origin[sourceKey], source[sourceKey])
);
if (changed) {
return {
...origin,
...source,
};
}
return origin;
}
export function clearFeedbacks<T extends Errors | Warnings>(name: string, origin?: T) {
if (!origin) {
return origin;
}
if (name in origin) {
delete origin[name];
}
return origin;
}
export function shouldValidate(currentTrigger: ValidateTrigger, formTrigger?: ValidateTrigger) {
return currentTrigger === formTrigger;
}
export function getValidByErrors(errors: Errors | undefined) {
return errors ? Object.keys(errors).every((name) => isEmpty(errors[name])) : true;
}
export namespace FieldEventUtils {
export function shouldTriggerFieldChangeEvent(
payload: OnFormValuesChangePayload,
fieldName: string
) {
const { name: changedName, options } = payload;
// 如果 Field 是 变更path 的 ancestor 则触发
if (Glob.isMatchOrParent(fieldName, changedName)) {
return true;
}
// 如果 Field 是 变更path 的 child 或 grandchild 有条件触发
if (new Path(changedName).isChildOrGrandChild(fieldName)) {
// 数组情况下部分子项不触发变更
// 1. 数组 append 触发的FormValuesChange 不需要触发其子 Field 的 onValueChange
if (options?.action === 'array-append') {
return !new Path(changedName).isChildOrGrandChild(fieldName);
}
// 2. 数组 splice 触发的FormValuesChange 无需触发第一个删除项前的所有子 Field 的 onValueChange
else if (options?.action === 'array-splice' && options?.indexes?.length) {
return (
(Path.compareArrayPath(
new Path(fieldName),
new Path(changedName).concat(options.indexes[0])
) as number) >= 0
);
}
// 其余情况都需要触发
return true;
}
return false;
}
export function shouldTriggerFieldValidateWhenChange(
payload: OnFormValuesChangePayload,
fieldName: string
) {
const { name: changedName, options } = payload;
if (options?.action === 'array-splice' || options?.action === 'array-swap') {
// const splicedIndexes = options?.indexes || [];
//
// const splicedPaths = splicedIndexes.map(index => new Path(changedName).concat(index));
// const removedPaths = Array.from({ length: splicedIndexes.length }, (_, i) =>
// new Path(changedName).concat(prevValues[changedName].length - i - 1),
// );
//
// const ignoredPathOrParentPaths = [...splicedPaths, ...removedPaths];
// // const ignoredPathOrParentPaths = splicedPaths;
// if (
// ignoredPathOrParentPaths.some(
// path => path.toString() === fieldName || path.isChildOrGrandChild(fieldName),
// )
// ) {
// return false;
// }
// splice 和 swap 都属于数组跟级别的变更,仅需触发数组field的校验, 无需校验子项
return fieldName === changedName;
}
return FieldEventUtils.shouldTriggerFieldChangeEvent(payload, fieldName);
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './react';
export type {
FormRenderProps,
FieldRenderProps,
FieldArrayRenderProps,
FieldState,
FormState,
Validate,
FormControl,
FieldName,
FieldError,
FieldWarning,
FormValidateReturn,
FieldValue,
FieldArray as IFieldArray,
Field as IField,
Form as IForm,
Errors,
Warnings,
} from './types';
export { ValidateTrigger, FeedbackLevel } from './types';
export { createForm, type CreateFormOptions } from './core/create-form';
export { Glob } from './utils';
export * from './core';
@@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
export const FormModelContext = React.createContext<any>({});
export const FieldModelContext = React.createContext<any>({});
@@ -0,0 +1,122 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
import { isFunction } from 'lodash-es';
import { DisposableCollection, useRefresh } from '@flowgram.ai/utils';
import { useReadonlyReactiveState } from '@flowgram.ai/reactive';
import {
FieldArrayOptions,
FieldArrayRenderProps,
FieldModelState,
FieldName,
FieldValue,
} from '../types/field';
import { FormModelState } from '../types';
import { toFieldArray } from '../core/to-field-array';
import { FieldArrayModel } from '../core/field-array-model';
import { toFieldState, toFormState } from '../core';
import { useFormModel } from './utils';
import { FieldModelContext } from './context';
export type FieldArrayProps<TValue> = FieldArrayOptions<TValue> & {
/**
* A React element or a render prop
*/
children?: ((props: FieldArrayRenderProps<TValue>) => React.ReactElement) | React.ReactElement;
/**
* Dependencies of the current field. If a field name is given in deps, current field will re-render if the given field name data is updated
*/
deps?: FieldName[];
};
/**
* HOC That declare an array field, an FieldArray model will be created when it's rendered. Multiple FieldArray rendering with a same name will link to the same model, which means they shared data、 status and methods
*/
export function FieldArray<TValue extends FieldValue>({
name,
defaultValue,
deps,
render,
children,
}: FieldArrayProps<TValue>): React.ReactElement {
const formModel = useFormModel();
const fieldModel =
formModel.getField<FieldArrayModel<TValue>>(name) ||
(formModel.createFieldArray(name) as FieldArrayModel<any>);
const field = React.useMemo(() => toFieldArray<TValue>(fieldModel), [fieldModel]);
const refresh = useRefresh();
const fieldModelState = useReadonlyReactiveState<FieldModelState>(fieldModel.reactiveState);
const formModelState = useReadonlyReactiveState<FormModelState>(formModel.reactiveState);
const fieldState = toFieldState(fieldModelState);
const formState = React.useMemo(() => toFormState(formModelState), [formModelState]);
React.useEffect(() => {
// 当 FieldArray 加上 key 且 key 变化时候会销毁 FieldModel
if (fieldModel.disposed) {
refresh();
return () => {};
}
fieldModel.renderCount = fieldModel.renderCount + 1;
if (!formModel.getValueIn(name) !== undefined && defaultValue !== undefined) {
formModel.setInitValueIn(name, defaultValue);
refresh();
}
const disposableCollection = new DisposableCollection();
disposableCollection.push(
fieldModel.onValueChange(() => {
refresh();
})
);
if (deps) {
deps.forEach((dep) => {
const disposable = formModel.getField(dep)?.onValueChange(() => {
refresh();
});
if (disposable) {
disposableCollection.push(disposable);
}
});
}
return () => {
disposableCollection.dispose();
if (fieldModel.renderCount > 1) {
fieldModel.renderCount = fieldModel.renderCount - 1;
} else {
const newFieldModel = formModel.getField(fieldModel.name);
if (newFieldModel === fieldModel) fieldModel.dispose();
}
};
}, [fieldModel]);
const renderInner = () => {
if (render && isFunction(render)) {
// @ts-ignore
return render({ field, fieldState, formState });
}
if (isFunction(children)) {
return children({ field, fieldState, formState });
}
return <>Invalid Array render</>;
};
if (fieldModel.disposed) return <></>;
return (
<FieldModelContext.Provider value={fieldModel}>{renderInner()}</FieldModelContext.Provider>
);
}
@@ -0,0 +1,112 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import * as React from 'react';
import { isFunction } from 'lodash-es';
import { DisposableCollection, useRefresh } from '@flowgram.ai/utils';
import { useReadonlyReactiveState } from '@flowgram.ai/reactive';
import { toField, toFieldState } from 'src/core/to-field';
import { FieldModelState, FieldName, FieldOptions, FieldRenderProps } from '../types/field';
import { FormModelState } from '../types';
import { toFormState } from '../core/to-form';
import { useFormModel } from './utils';
import { FieldModelContext } from './context';
export type FieldProps<TValue> = FieldOptions<TValue> & {
/**
* A React element or a render prop
*/
children?: ((props: FieldRenderProps<TValue>) => React.ReactElement) | React.ReactElement;
/**
* Dependencies of the current field. If a field name is given in deps, current field will re-render if the given field name data is updated
*/
deps?: FieldName[];
};
/**
* HOC That declare a field, an Field model will be created it's rendered. Multiple Field rendering with a same name will link to the same model, which means they shared data、 status and methods
*/
export function Field<TValue>({
name,
defaultValue,
render,
children,
deps,
}: FieldProps<TValue>): React.ReactElement {
const formModel = useFormModel();
const fieldModel = formModel.getField(name) || formModel.createField(name);
const field = React.useMemo(() => toField<TValue>(fieldModel), [fieldModel]);
const fieldModelState = useReadonlyReactiveState<FieldModelState>(fieldModel.reactiveState);
const formModelState = useReadonlyReactiveState<FormModelState>(formModel.reactiveState);
const fieldState = React.useMemo(() => toFieldState(fieldModelState), [fieldModelState]);
const formState = toFormState(formModelState);
const refresh = useRefresh();
React.useEffect(() => {
// 当 Field 加上 key 且 key 变化时候会销毁 FieldModel
if (fieldModel.disposed) {
refresh();
return () => {};
}
fieldModel.renderCount = fieldModel.renderCount + 1;
if (!formModel.getValueIn(name) !== undefined && defaultValue !== undefined) {
formModel.setInitValueIn(name, defaultValue);
refresh();
}
const disposableCollection = new DisposableCollection();
disposableCollection.push(
fieldModel.onValueChange(() => {
refresh();
})
);
if (deps) {
deps.forEach((dep) => {
const disposable = formModel.getField(dep)?.onValueChange(() => {
refresh();
});
if (disposable) {
disposableCollection.push(disposable);
}
});
}
return () => {
disposableCollection.dispose();
if (fieldModel.renderCount > 1) {
fieldModel.renderCount = fieldModel.renderCount - 1;
} else {
const newFieldModel = formModel.getField(fieldModel.name);
if (newFieldModel === fieldModel) fieldModel.dispose();
}
};
}, [fieldModel]);
const renderInner = () => {
if (render) {
return render({ field, fieldState, formState });
}
if (isFunction(children)) {
return children({ field, fieldState, formState });
}
return React.cloneElement(children as React.ReactElement, { ...field });
};
if (fieldModel.disposed) return <></>;
return (
<FieldModelContext.Provider value={fieldModel}>{renderInner()}</FieldModelContext.Provider>
);
}
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { Children, useEffect, useMemo } from 'react';
import { isFunction } from 'lodash-es';
import { toForm } from 'src/core/to-form';
import { FormControl, FormOptions, FormRenderProps } from '../types/form';
import { createForm } from '../core/create-form';
import { FormModelContext } from './context';
export type FormProps<TValues> = FormOptions & {
/**
* React children or child render prop
*/
children?: ((props: FormRenderProps<TValues>) => React.ReactNode) | React.ReactNode;
/**
* If this prop is set to true, Form instance will be kept event thought<Form /> is destroyed.
* This means you can still use some form's api such as Form.validate and Form.setValueIn to handle pure data logic.
* @default false
*/
keepModelOnUnMount?: boolean;
/**
* provide form instance from outside. if control is given Form will use the form instance in the control instead of creating one.
*/
control?: FormControl<TValues>;
};
/**
* `FormContentRender` allows you to write `useWatch` to `formMeta.render`
*/
function FormContentRender(
props: { render: (props: FormRenderProps<any>) => React.ReactNode } & FormRenderProps<any>
): JSX.Element {
const { form, render } = props;
return <>{render({ form })}</>;
}
/**
* Hoc That init and provide Form instance. You can also provide form instance from outside by using control prop
* @param props
*/
export function Form<TValues>(props: FormProps<TValues>) {
const { children, keepModelOnUnMount = false, control, ...restOptions } = props;
const { _formModel: formModel } = useMemo(
() => (control ? control : createForm(restOptions).control),
[control]
);
useEffect(
() => () => {
// 组件销毁时,销毁formModel
if (!keepModelOnUnMount) {
formModel.dispose();
}
},
[]
);
const form = useMemo(() => toForm<TValues>(formModel), [formModel]);
return (
<FormModelContext.Provider value={formModel}>
{children ? (
isFunction(children) ? (
<FormContentRender form={form} render={children} />
) : (
Children.only(children)
)
) : null}
</FormModelContext.Provider>
);
}
@@ -0,0 +1,15 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './field';
export * from './form';
export * from './use-form';
export * from './use-watch';
export * from './field-array';
export * from './use-field';
export * from './use-form-state';
export * from './use-field-validate';
export * from './use-current-field';
export * from './use-current-field-state';
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useContext, useMemo } from 'react';
import { useReadonlyReactiveState } from '@flowgram.ai/reactive';
import { FieldModelState, FieldState } from '../types';
import { toFieldState } from '../core';
import { FieldModelContext } from './context';
/**
* Get the current field state. It should be used in a child component of <Field />, otherwise it throws an error
*/
export function useCurrentFieldState(): FieldState {
const fieldModel = useContext(FieldModelContext);
if (!fieldModel) {
throw new Error(
`[Form] useCurrentField Error: field not found, make sure that you are using this hook in a child Component of a Field`
);
}
const fieldModelState = useReadonlyReactiveState<FieldModelState>(fieldModel.reactiveState);
return useMemo(() => toFieldState(fieldModelState), [fieldModelState]);
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useContext } from 'react';
import { Field, FieldArray, FieldValue } from '../types';
import { toField } from '../core/to-field';
import { toFieldArray } from '../core';
import { FieldModelContext } from './context';
/**
* Get the current Field. It should be used in a child component of <Field />, otherwise it throws an error
*/
export function useCurrentField<
TFieldValue = FieldValue,
TField extends Field<TFieldValue> | FieldArray<TFieldValue> = Field<TFieldValue>
>(): Field<TFieldValue> | FieldArray<TFieldValue> {
const fieldModel = useContext(FieldModelContext);
if (!fieldModel) {
throw new Error(
`[Form] useCurrentField Error: field not found, make sure that you are using this hook in a child Component of a Field`
);
}
return fieldModel.map
? (toFieldArray<TFieldValue>(fieldModel) as unknown as FieldArray<TFieldValue>)
: (toField(fieldModel) as unknown as TField);
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useContext } from 'react';
import { FieldName } from '../types';
import { useFormModel } from './utils';
import { FieldModelContext } from './context';
/**
* Get validate method of a field with given name. the returned function could possibly do nothing if the field is not found.
* The reason could be that the field is not rendered yet or the name given is wrong.
* @param name
*/
export function useFieldValidate(name?: FieldName): () => void {
const currentFieldModel = useContext(FieldModelContext);
const formModel = useFormModel();
return useCallback(() => {
const fieldModel = name ? formModel.getField(name!) : currentFieldModel;
fieldModel?.validate();
}, [currentFieldModel]);
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useContext, useEffect } from 'react';
import { Disposable, useRefresh } from '@flowgram.ai/utils';
import { Field, FieldArray, FieldName, FieldValue } from '../types';
import { toField } from '../core/to-field';
import { toFieldArray } from '../core';
import { useFormModel } from './utils';
import { FieldModelContext } from './context';
/**
* @deprecated
* `useField` is deprecated because its return relies on React render. if the Field is not rendered, the return would be
* undefined. If you simply want to monitor the change of the value of a certain path, please use `useWatch(fieldName)`
* @param name
*/
export function useField<
TFieldValue = FieldValue,
TField extends Field<TFieldValue> | FieldArray<TFieldValue> = Field<TFieldValue>
>(name?: FieldName): TField | undefined {
const currentFieldModel = useContext(FieldModelContext);
const formModel = useFormModel();
const refresh = useRefresh();
const fieldModel = name ? formModel.getField(name!) : currentFieldModel;
useEffect(() => {
let disposable: Disposable;
if (fieldModel) {
disposable = fieldModel.onValueChange(() => refresh());
}
return () => {
disposable?.dispose();
};
}, [fieldModel]);
if (!fieldModel) {
return undefined;
}
if (fieldModel.map) {
return toFieldArray<TFieldValue>(fieldModel) as unknown as TField;
}
return toField(fieldModel) as unknown as TField;
}
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useObserve } from '@flowgram.ai/reactive';
import { Form, FormControl, FormState } from '../types';
export function useFormState(control?: FormControl<any> | Form) {
// @ts-ignore
return useObserve<FormState>(control?._formModel.reactiveState.value || ({} as FormState));
}
export function useFormErrors(control?: FormControl<any> | Form) {
// @ts-ignore
return useObserve<FormState>(control?._formModel.reactiveState.value || ({} as FormState))
?.errors;
}
export function useFormWarnings(control?: FormControl<any> | Form) {
// @ts-ignore
return useObserve<FormState>(control?._formModel.reactiveState.value || ({} as FormState))
?.warnings;
}
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Form } from '../types';
import { toForm } from '../core/to-form';
import { useFormModel } from './utils';
/**
* Get Form instance. It should be use in a child component of <Form />
*/
export function useForm(): Form {
const formModel = useFormModel();
return toForm(formModel);
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/utils';
import { FieldName, FieldValue } from '../types';
import { useFormModel } from './utils';
/**
* Listen to the field data change and refresh the React component.
* @param name the field's uniq name (path)
*/
export function useWatch<TValue = FieldValue>(name: FieldName): TValue {
const refresh = useRefresh();
const formModel = useFormModel();
if (!formModel) {
throw new Error('[Form] error in useWatch, formModel not found');
}
const value = formModel.getValueIn<TValue>(name);
useEffect(() => {
const disposable = formModel.onFormValuesUpdated(({ name: updatedName }) => {
if (updatedName === name) {
refresh();
}
});
return () => disposable.dispose();
}, [name, formModel]);
return value;
}
@@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useContext } from 'react';
import { FormModel } from '../core/form-model';
import { FormModelContext } from './context';
export function useFormModel(): FormModel {
return useContext<FormModel>(FormModelContext);
}
@@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export type Context = any;
@@ -0,0 +1,193 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { Errors, FieldError, FieldWarning, Warnings } from './validate';
import { FormState } from './form';
export type NativeFieldValue = string | number | boolean | null | undefined | unknown[];
export type FieldValue = any;
export type FieldArrayValue = Array<any> | undefined;
export type FieldName = string;
export type CustomElement = Partial<HTMLElement> & {
name: FieldName;
type?: string;
value?: any;
disabled?: boolean;
checked?: boolean;
options?: HTMLOptionsCollection;
files?: FileList | null;
focus?: () => void;
};
export type FieldElement =
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement
| CustomElement;
export type Ref = FieldElement;
/**
* Field render model, it's only available when Field is rendered
*/
export interface Field<
TFieldValue extends FieldValue = FieldValue,
E = React.ChangeEvent<any> | TFieldValue
> {
/**
* Uniq key for the Field, you can use it for the child react component's uniq key.
*/
key: string;
/**
* A function which sends the input's value to Field.
* It should be assigned to the onChange prop of the input component
* @param e It can be the new value of the field or the event sent by original dom input or checkbox component.
*/
onChange: (e: E) => void;
/**
* The current value of Field
*/
value: TFieldValue;
/**
* Field's name (path)
*/
name: FieldName;
/**
* A function which sends the input's onFocus event to Field. It should be assigned to the input's onFocus prop.
*/
onFocus?: () => void;
/**
* A function which sends the input's onBlur event to Field. It should be assigned to the input's onBlur prop.
*/
onBlur?: () => void;
}
/**
* FieldArray render model, it's only available when FieldArray is rendered
*/
export interface FieldArray<TFieldValue extends FieldValue = FieldValue>
extends Field<Array<TFieldValue> | undefined, Array<TFieldValue> | undefined> {
/**
* Same as native Array.map, the first param of the callback function is the child field of this FieldArray.
* @param cb callback function
*/
map: <T = any>(cb: (f: Field<TFieldValue>, index: number) => T) => T[];
/**
* Append a value at the end of the array, it will create a new Field for this value as well.
* @param value the value to append
*/
append: (value: TFieldValue) => Field<TFieldValue>;
/**
* @deprecated use remove instead
* Delete the value and the related field at certain index of the array.
* @param index the index of the element to delete
*/
delete: (index: number) => void;
/**
* Delete the value and the related field at certain index of the array.
* @param index the index of the element to delete
*/
remove: (index: number) => void;
/**
* Move an array element from one position to another.
* @param from from position
* @param to to position
*/
move: (from: number, to: number) => void;
/**
* Swap the position of two elements of the array.
* @param from
* @param to
*/
swap: (from: number, to: number) => void;
}
export interface FieldOptions<TValue, TFormValues = any> {
/**
* Field's name(path), it should be uniq within a form instance.
* Two Fields Rendered with the same name will link to the same part of data and field status such as errors is shared.
*/
name: FieldName;
/**
* Default value of the field. Please notice that Field is a render model, so this default value will only be set when
* the field is rendered. If you want to give a default value before field rendering, please set it in the Form's defaultValue.
*/
defaultValue?: TValue;
/**
* This is a render prop. A function that returns a React element and provides the ability to attach events and value into the component.
* This simplifies integrating with external controlled components with non-standard prop names. Provides field、fieldState and formState, to the child component.
* @param props
*/
render?: (props: FieldRenderProps<TValue>) => React.ReactElement;
}
export interface FieldRenderProps<TValue> {
field: Field<TValue>;
fieldState: Readonly<FieldState>;
formState: Readonly<FormState>;
}
export interface FieldArrayOptions<TValue> {
/**
* Field's name(path), it should be uniq within a form instance.
* Two Fields Rendered with the same name will link to the same part of data and field status such as errors is shared.
*/
name: FieldName;
/**
* Default value of the field. Please notice that Field is a render model, so this default value will only be set when
* the field is rendered. If you want to give a default value before field rendering, please set it in the Form's initialValues.
*/
defaultValue?: TValue[];
/**
* This is a render prop. A function that returns a React element and provides the ability to attach events and value into the component.
* This simplifies integrating with external controlled components with non-standard prop names. Provides field、fieldState and formState, to the child component.
* @param props
*/
render?: (props: FieldArrayRenderProps<TValue>) => React.ReactElement;
}
export interface FieldArrayRenderProps<TValue> {
field: FieldArray<TValue>;
fieldState: Readonly<FieldState>;
formState: Readonly<FormState>;
}
export interface UseFieldReturn {}
export interface FieldState {
/**
* If field value is invalid
*/
invalid: boolean;
/**
* If field input component is touched by user
*/
isTouched: boolean;
/**
* If field current value is different from the initialValue.
*/
isDirty: boolean;
/**
* If field is validating.
*/
isValidating: boolean;
/**
* Field errors, empty array means there is no errors.
*/
errors?: FieldError[];
/**
* Field warnings, empty array means there is no warnings.
*/
warnings?: FieldWarning[];
}
export interface FieldModelState extends Omit<FieldState, 'errors' | 'warnings'> {
errors?: Errors;
warnings?: Warnings;
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormModel } from '../core/form-model';
import { Errors, FormValidateReturn, Validate, ValidateTrigger, Warnings } from './validate';
import { Field, FieldArray, FieldName, FieldValue } from './field';
import { Context } from './common';
export interface FormState {
// isLoading: boolean;
/**
* If the form data is valid
*/
invalid: boolean;
/**
* If the form data is different from the intialValues
*/
isDirty: boolean;
/**
* If the form fields have been touched
*/
isTouched: boolean;
/**
* If the form is during validation
*/
isValidating: boolean;
/**
* Form errors
*/
errors?: Errors;
/**
* Form warnings
*/
warnings?: Warnings;
}
export interface FormModelState extends Omit<FormState, 'errors' | 'warnings'> {
errors?: Errors;
warnings?: Warnings;
}
export interface FormOptions<TValues = any> {
/**
* InitialValues of the form.
*/
initialValues?: TValues;
/**
* When should the validation trigger, for example onChange or onBlur.
*/
validateTrigger?: ValidateTrigger;
/**
* Form data's validation rules. It's a key value map, where the key is a pattern of data's path (or field name), the value is a validate function.
*/
validate?:
| Record<string, Validate>
| ((value: TValues, ctx: Context) => Record<string, Validate>);
/**
* Custom context. It will be accessible via form instance or in validate function.
*/
context?: Context;
}
export interface Form<TValues = any> {
/**
* The initialValues of the form.
*/
initialValues: TValues;
/**
* Form values. Returns a deep copy of the data in the store.
*/
values: TValues;
/**
* Form state
*/
state: FormState;
/**
* Get value in certain path
* @param name path
*/
getValueIn<TValue = FieldValue>(name: FieldName): TValue;
/**
* Set value in certain path.
* It will trigger the re-rendering of the Field Component if a Field is related to this path
* @param name path
*/
setValueIn<TValue>(name: FieldName, value: TValue): void;
/**
* Trigger validate for the whole form.
*/
validate: () => Promise<FormValidateReturn>;
}
export interface FormRenderProps<TValues> {
/**
* Form instance.
*/
form: Form<TValues>;
}
export interface FormControl<TValues> {
_formModel: FormModel<TValues>;
getField: <
TValue = FieldValue,
TField extends Field<TValue> | FieldArray<TValue> = Field<TValue>
>(
name: FieldName
) => Field<TValue> | FieldArray<TValue> | undefined;
/** 手动初始化form */
init: () => void;
}
export interface CreateFormReturn<TValues> {
form: Form<TValues>;
control: FormControl<TValues>;
}
export interface OnFormValuesChangeOptions {
action?: 'array-append' | 'array-splice' | 'array-swap';
indexes?: number[];
}
export interface OnFormValuesChangePayload {
values: FieldValue;
prevValues: FieldValue;
name: FieldName;
options?: OnFormValuesChangeOptions;
}
export interface OnFormValuesInitPayload {
values: FieldValue;
prevValues: FieldValue;
name: FieldName;
}
export interface OnFormValuesUpdatedPayload {
values: FieldValue;
prevValues: FieldValue;
name: FieldName;
options?: OnFormValuesChangeOptions;
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './field';
export * from './form';
export * from './validate';
@@ -0,0 +1,87 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MaybePromise } from '@flowgram.ai/utils';
import { FieldName } from './field';
import { Context } from './common';
export enum FeedbackLevel {
Error = 'error',
Warning = 'warning',
}
export interface Feedback<FeedbackLevel> {
/**
* The data path (or field path) that generate this feedback
*/
name: string;
/**
* The type of the feedback
*/
type?: string;
/**
* Feedback level
*/
level: FeedbackLevel;
/**
* Feedback message
*/
message: string | React.ReactNode;
}
export type FieldError = Feedback<FeedbackLevel.Error>;
export type FieldWarning = Feedback<FeedbackLevel.Warning>;
export type FormErrorOptions = Omit<FieldError, 'name'>;
export type FormWarningOptions = Omit<FieldWarning, 'name'>;
export type FeedbackOptions<FeedbackLevel> = Omit<Feedback<FeedbackLevel>, 'name'>;
export type Validate<TFieldValue = any, TFormValues = any> = (props: {
/**
* Value of the data to validate
*/
value: TFieldValue;
/**
* Complete form values
*/
formValues: TFormValues;
/**
* The path of the data we are validating
*/
name: FieldName;
/**
* The custom context set when init form
*/
context: Context;
}) =>
| MaybePromise<string>
| MaybePromise<FormErrorOptions>
| MaybePromise<FormWarningOptions>
| MaybePromise<undefined>;
export function isFieldError(f: Feedback<any>): f is FieldError {
if (f.level === FeedbackLevel.Error) {
return true;
}
return false;
}
export function isFieldWarning(f: Feedback<any>): f is FieldWarning {
if (f.level === FeedbackLevel.Warning) {
return true;
}
return false;
}
export type Errors = Record<FieldName, FieldError[]>;
export type Warnings = Record<FieldName, FieldWarning[]>;
export enum ValidateTrigger {
onChange = 'onChange',
onBlur = 'onBlur',
}
export type FormValidateReturn = (FieldError | FieldWarning)[];
@@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export function isReactChangeEvent(e: unknown): e is React.ChangeEvent<HTMLInputElement> {
return (
typeof e === 'object' &&
e !== null &&
'target' in e &&
typeof (e as React.ChangeEvent<any>).target === 'object'
);
}
export function isCheckBoxEvent(e: unknown): e is React.ChangeEvent<HTMLInputElement> {
return (
typeof e === 'object' &&
e !== null &&
'target' in e &&
typeof (e as React.ChangeEvent<HTMLInputElement>).target === 'object' &&
(e as React.ChangeEvent<HTMLInputElement>).target.type === 'checkbox'
);
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Emitter } from '@flowgram.ai/utils';
interface Payload<T> {
origin?: T;
current?: T;
}
export class EmitterChain<T> {
protected emitter: Emitter<Payload<T>>;
constructor() {
this.emitter = new Emitter<Payload<T>>();
}
get event() {
return this.emitter.event;
}
_fire(current?: T, origin?: T) {
this.emitter.fire({ current, origin });
}
fire(current: T, next?: EmitterChain<T>) {
this._fire(current);
next?._fire(undefined, current);
}
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { flatten, get, isArray, isObject } from 'lodash-es';
export namespace Glob {
export const DIVIDER = '.';
export const ALL = '*';
// 仅支持一个通配符
export function isMatch(pattern: string, path: string) {
const patternArr = pattern.split(DIVIDER);
const pathArr = path.split(DIVIDER);
if (patternArr.length !== pathArr.length) {
return false;
}
return patternArr.every((pattern, index) => {
if (pattern === ALL) {
return true;
}
return pattern === pathArr[index];
});
}
/**
* 判断pattern 是否match pattern 或其parent
* @param pattern
* @param path
*/
export function isMatchOrParent(pattern: string, path: string) {
if (pattern === '') {
return true;
}
const patternArr = pattern.split(DIVIDER);
const pathArr = path.split(DIVIDER);
if (patternArr.length > pathArr.length) {
return false;
}
for (let i = 0; i < patternArr.length; i++) {
if (patternArr[i] !== ALL && patternArr[i] !== pathArr[i]) {
return false;
}
}
return true;
}
/**
* 从 path 中提取出匹配pattern 的 parent path,包括是 path 自身
* 该方法默认 isMatchOrParent(pattern, path) 为 true, 不做为false 的错误处理。
* @param pattern
* @param path
*/
export function getParentPathByPattern(pattern: string, path: string) {
const patternArr = pattern.split(DIVIDER);
const pathArr = path.split(DIVIDER);
return pathArr.slice(0, patternArr.length).join(DIVIDER);
}
function concatPath(p1: string | number, ...pathArr: (string | number)[]): string {
const p2 = pathArr.shift();
if (p2 === undefined) return p1.toString();
let resultPath = '';
if (p1 === '' && p2 === '') {
resultPath = '';
} else if (p1 !== '' && p2 === '') {
resultPath = p1.toString();
} else if (p1 === '' && p2 !== '') {
resultPath = p2.toString();
} else {
resultPath = `${p1}${DIVIDER}${p2}`;
}
if (pathArr.length > 0) {
return concatPath(resultPath, ...pathArr);
}
return resultPath;
}
/**
* 找到 obj 在给与 paths 下所有子path
* @param paths
* @param obj
* @private
*/
export function getSubPaths(paths: string[], obj: any): string[] {
if (!obj || typeof obj !== 'object') {
return [];
}
return flatten(
paths.map((path) => {
const value = path === '' ? obj : get(obj, path);
if (isArray(value)) {
return value.map((_: any, index: number) => concatPath(path, index));
} else if (isObject(value)) {
return Object.keys(value).map((key) => concatPath(path, key));
}
return [];
})
);
}
/**
* 将带有通配符的 path pattern 分割。如 a.b.*.c.*.d, 会被分割成['a.b','*','c','*','d']
* @param pattern
* @private
*/
export function splitPattern(pattern: string): string[] {
const parts = pattern.split(DIVIDER);
const res: string[] = [];
let i = 0;
let curPath: string[] = [];
while (i < parts.length) {
if (parts[i] === ALL) {
if (curPath.length) {
res.push(curPath.join(DIVIDER));
}
res.push(ALL);
curPath = [];
} else {
curPath.push(parts[i]);
}
i += 1;
}
if (curPath.length) {
res.push(curPath.join(DIVIDER));
}
return res;
}
/**
* Find all paths matched pattern in object. If withEmptyValue is true, it will include
* paths whoes value is undefined.
* @param obj
* @param pattern
* @param withEmptyValue
*/
export function findMatchPaths(obj: any, pattern: string, withEmptyValue?: boolean): string[] {
if (!obj || !pattern) {
return [];
}
const nextPaths: string[] = pattern.split(DIVIDER);
let curKey: string | undefined = nextPaths.shift();
let curPaths: string[] = [];
let curValue = obj;
while (curKey) {
let isObject = typeof curValue === 'object' && curValue !== null;
if (!isObject) return [];
// 匹配 *
if (curKey === ALL) {
const parentPath = curPaths.join(DIVIDER);
return flatten(
Object.keys(curValue).map((key) => {
if (nextPaths.length === 0) {
return concatPath(parentPath, key);
}
return findMatchPaths(curValue[key], `${nextPaths.join(DIVIDER)}`, withEmptyValue).map(
(p) => concatPath(parentPath, key, p)
);
})
);
}
// 找不到对应 key 则不匹配
if (!(curKey in curValue) && !withEmptyValue) {
return [];
}
curValue = curValue[curKey!];
curPaths.push(curKey);
curKey = nextPaths.shift();
}
return [pattern];
// const parts = splitPattern(pattern);
//
// let prePaths: string[] = [''];
// let curPath: string = '';
//
// for (let i in parts) {
// const part = parts[i];
// if (part === ALL) {
// prePaths = getSubPaths(
// prePaths.map(p => concatPath(p, curPath)),
// obj,
// );
// curPath = '';
// } else {
// curPath = part;
//
// /**
// * 过滤掉后续path 值不存在的prePath
// * 为什么: prePaths 是返回前一个通配符下所有的路径,但每个路径下的数据的field 可能不同
// * 这会导致一些prePath 不存在后面所需的路径。如以下场景
// * const obj = {
// * a: { b: { c: 1 } },
// * x: { y: { z: 2 } },
// * };
// * expect(Glob.findMatchPaths(obj, '*.y')).toEqual(['x.y']);
// */
//
// prePaths = prePaths.filter(p => {
// const preValue = p ? get(obj, p) : obj;
// if (typeof preValue === 'object') {
// return curPath in preValue;
// }
// return true;
// });
// }
// }
//
// if (curPath) {
// return prePaths.map(p => [p, curPath].join(DIVIDER));
// }
// return prePaths;
}
/**
* Find all paths matched pattern in object, including paths whoes value is undefined.
* @param obj
* @param pattern
*/
export function findMatchPathsWithEmptyValue(obj: any, pattern: string): string[] {
if (!pattern.includes('*')) {
return [pattern];
}
return findMatchPaths(obj, pattern, true);
}
// export function findMatchPathsWithEmptyValue(obj: any, pattern: string) {
// const parts = splitPattern(pattern);
//
// let prePaths: string[] = [''];
// let curPath: string = '';
//
// for (let i in parts) {
// const part = parts[i];
// if (part === ALL) {
// prePaths = getSubPaths(
// prePaths.map(p => concatPath(p, curPath)),
// obj,
// );
// curPath = '';
// } else {
// curPath = part;
//
// /**
// * 过滤掉后续path 值不存在的prePath
// * 为什么: prePaths 是返回前一个通配符下所有的路径,但每个路径下的数据的field 可能不同
// * 这会导致一些prePath 不存在后面所需的路径。如以下场景
// * const obj = {
// * a: { b: { c: 1 } },
// * x: { y: { z: 2 } },
// * };
// * expect(Glob.findMatchPaths(obj, '*.y')).toEqual(['x.y']);
// */
//
// // prePaths = prePaths.filter(p => {
// // const preValue = p ? get(obj, p) : obj;
// // if (typeof preValue === 'object') {
// // return curPath in preValue;
// // }
// // return true;
// // });
// }
// }
//
// if (curPath) {
// return prePaths.map(p => [p, curPath].join(DIVIDER));
// }
// return prePaths;
// }
}
@@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './object';
export * from './dom';
export * from './glob';
@@ -0,0 +1,107 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { clone, toPath } from 'lodash-es';
/**
* These functions are copied from Formik.
* @see https://github.com/jaredpalmer/formik
*/
export const isEmptyArray = (value?: any) => Array.isArray(value) && value.length === 0;
/** @private is the given object a Function? */
export const isFunction = (obj: any): obj is Function => typeof obj === 'function';
/** @private is the given object an Object? */
export const isObject = (obj: any): obj is Object => obj !== null && typeof obj === 'object';
/** @private is the given object an integer? */
export const isInteger = (obj: any): boolean => String(Math.floor(Number(obj))) === obj;
/** @private is the given object a string? */
export const isString = (obj: any): obj is string =>
Object.prototype.toString.call(obj) === '[object String]';
/** @private is the given object a NaN? */
// eslint-disable-next-line no-self-compare
export const isNaN = (obj: any): boolean => obj !== obj;
/** @private is the given object/value a promise? */
export const isPromise = (value: any): value is PromiseLike<any> =>
isObject(value) && isFunction(value.then);
/**
* Deeply get a value from an object via its path.
*/
export function getIn(obj: any, key: string | string[], def?: any, p: number = 0) {
const path = toPath(key);
while (obj && p < path.length) {
obj = obj[path[p++]];
}
// check if path is not in the end
if (p !== path.length && !obj) {
return def;
}
return obj === undefined ? def : obj;
}
/**
* Deeply set a value from in object via its path. If the value at `path`
* has changed, return a shallow copy of obj with `value` set at `path`.
* If `value` has not changed, return the original `obj`.
*
* Existing objects / arrays along `path` are also shallow copied. Sibling
* objects along path retain the same internal js reference. Since new
* objects / arrays are only created along `path`, we can test if anything
* changed in a nested structure by comparing the object's reference in
* the old and new object, similar to how russian doll cache invalidation
* works.
*/
export function shallowSetIn(obj: any, path: string, value: any): any {
let res: any = clone(obj); // this keeps inheritance when obj is a class
let resVal: any = res;
let i = 0;
let pathArray = toPath(path);
for (; i < pathArray.length - 1; i++) {
const currentPath: string = pathArray[i];
let currentObj: any = getIn(obj, pathArray.slice(0, i + 1));
if (currentObj && (isObject(currentObj) || Array.isArray(currentObj))) {
resVal = resVal[currentPath] = clone(currentObj);
} else {
const nextPath: string = pathArray[i + 1];
resVal = resVal[currentPath] = isInteger(nextPath) && Number(nextPath) >= 0 ? [] : {};
}
}
// Return original object if new value is the same as current
// `pathArray[i] in obj` is to supoort set undefined value with unknown key
if ((i === 0 ? obj : resVal)[pathArray[i]] === value && pathArray[i] in obj) {
return obj;
}
/**
* In Formik, they delete the key if the value is undefined. but here we keep the key with the undefined value.
* The reason that Formik tackle in this way is to fix the issue https://github.com/jaredpalmer/formik/issues/727
* Their fix is https://github.com/jaredpalmer/formik/issues/727, and we roll back to the code before this PR.
*/
resVal[pathArray[i]] = value;
return res;
}
export function keepValidKeys(obj: Record<string, any>, validKeys: string[]) {
const validKeysSet = new Set(validKeys);
const newObj: Record<string, any> = {};
Object.keys(obj).forEach((key) => {
if (validKeysSet.has(key)) {
newObj[key] = obj[key];
}
});
return newObj;
}
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
Errors,
Feedback,
FeedbackLevel,
FieldError,
FieldName,
FieldWarning,
FormErrorOptions,
FormWarningOptions,
} from '../types';
export function toFeedback(
result: string | FormErrorOptions | FormWarningOptions | undefined,
name: FieldName
): FieldError | FieldWarning | undefined {
if (typeof result === 'string') {
return {
name,
message: result,
level: FeedbackLevel.Error,
};
} else if (result?.message) {
return {
...result,
name,
};
}
}
export function feedbackToFieldErrorsOrWarnings<T>(name: string, feedback?: Feedback<any>) {
return {
[name]: feedback ? [feedback] : [],
} as T;
}
export const hasError = (errors: Errors) =>
Object.keys(errors).some((key) => errors[key]?.length > 0);
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
"rootDir": "./",
"baseUrl": "./",
},
"include": [
"./src"
],
"exclude": [
"node_modules",
"./__mocks__",
"./__tests__"
]
}
@@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
const path = require('path');
import { defineConfig } from 'vitest/config';
export default defineConfig({
build: {
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
globals: true,
mockReset: false,
environment: 'jsdom',
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
},
},
});