This commit is contained in:
@@ -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';
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user