zent
Version:
一套前端设计语言和基于React的实现
658 lines (586 loc) • 18.4 kB
text/typescript
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import identity from '../../../utils/identity';
import { BasicModel } from './basic';
import { ValidateOption } from '../validate';
import { INormalizeBeforeSubmit } from './field';
import { ModelRef } from './ref';
import type { BasicBuilder } from '../builders/basic';
import { get, or, Some } from '../maybe';
import { IModel } from './base';
import isNil from '../../../utils/isNil';
import uniqueId from '../../../utils/uniqueId';
import { pairwise, skip } from 'rxjs/operators';
import { createUnexpectedModelError } from '../error';
import { warningSubscribeValid, warningSubscribeValue } from '../warnings';
import { FIELD_ARRAY_ID, isModelRef, isModel } from './is';
import type { FieldArrayBuilder } from '../builders';
import { createSentinelSubject } from './sentinel-subject';
class FieldArrayModel<
Item,
Child extends IModel<Item> = IModel<Item>
> extends BasicModel<readonly Item[]> {
/**
* @internal
*/
[FIELD_ARRAY_ID]!: boolean;
protected readonly _displayName = 'FieldArrayModel';
readonly children$: BehaviorSubject<Child[]>;
owner: IModel<any> | null = null;
/**
* 当前 `FieldArrayModel` 对象的 builder 对象,仅在 `Model` 模式下可用。
*/
readonly builder?: FieldArrayBuilder<BasicBuilder<Item, Child>>;
private _valid$?: BehaviorSubject<boolean>;
private _value$?: BehaviorSubject<readonly Item[]>;
private readonly invalidModels: Set<BasicModel<Item>> = new Set();
private readonly mapModelToSubscriptions: Map<IModel<any>, Subscription[]> =
new Map();
private readonly childFactory: (defaultValue: Item) => Child;
/**
* 用于表单提交前格式化 `FieldArray` 值的回调函数
*/
normalizeBeforeSubmit: INormalizeBeforeSubmit<Item[], any> = identity;
/** @internal */
constructor(
childBuilder: BasicBuilder<Item, Child> | null,
private readonly defaultValue: readonly Item[]
) {
super(uniqueId('field-array-'));
this.childFactory = childBuilder
? (defaultValue: Item) => {
const child = childBuilder.build(Some(defaultValue));
return this._linkChild(child);
}
: (defaultValue: Item) =>
new ModelRef<Item, FieldArrayModel<Item, Child>, Child>(
null,
Some(defaultValue),
this
) as unknown as Child;
const children = this.defaultValue.map(this._buildChild);
this.children$ = new BehaviorSubject(children);
}
get value() {
if (this._value$) {
return this._value$.value;
}
return this.getRawValue();
}
get value$() {
return this._getValue$(true);
}
get valid$() {
return this._getValid$(true);
}
/**
* @internal
*
* The same as value$, but without warning
*/
_getValue$(shouldWarn = false) {
warningSubscribeValue(shouldWarn, this._displayName);
if (!this._value$) {
this._initValue$();
}
return this._value$;
}
_getValid$(shouldWarn = false) {
warningSubscribeValid(shouldWarn, this._displayName);
if (!this._valid$) {
this._initValid$();
}
return this._valid$;
}
/**
* 重置 `FieldArray` 为初始值,初始值通过 `initialize` 设置;如果初始值不存在就使用默认值
*/
reset() {
const children = or(this.initialValue, () => this.defaultValue).map(
this._buildChild
);
this.children$.next(children);
}
/**
* 清除 `FieldArray` 的初始值,并将当前值设置为默认值
*/
clear() {
this.initialValue = undefined;
const children = this.defaultValue.map(this._buildChild);
this.children$.next(children);
}
/**
* 清除 `FieldArray` 所有字段的错误信息
*/
clearError() {
this.error$.next(null);
const children = this.children;
const length = children.length;
for (let i = 0; i < length; i++) {
const element = children[i];
element.clearError();
}
}
/**
* 获取 `FieldArray` 内的所有 model
*/
get children(): ReadonlyArray<Child> {
return this.children$.getValue();
}
/**
* 获取指定下标的子 model
* @param index child model index
*/
get(index: number): Child {
return this.children[index];
}
/**
* 获取 `FieldArray` 内的原始值
*/
getRawValue() {
return this._getValue(model => model.getRawValue());
}
/**
* 获取 `FieldArray` 的用于表单提交的值,和原始值可能不一致
*/
getSubmitValue() {
const value = this._getValue(model => model.getSubmitValue());
return this.normalizeBeforeSubmit(value);
}
/**
* 修改 `FieldArray` 的值
* @param value 要修改的值
*/
patchValue(value: Item[]) {
const children = this.children$.getValue();
for (let i = 0; i < value.length; i += 1) {
if (i >= children.length) {
break;
}
const item = value[i];
const model = children[i];
if (isModelRef(model)) {
const m = model.getModel();
m && m.patchValue(item);
} else if (isModel(model)) {
model.patchValue(item);
}
}
if (value.length <= children.length) {
this.splice(value.length, children.length - value.length);
return;
}
for (let i = children.length; i < value.length; i += 1) {
const item = value[i];
this.push(item);
}
}
/**
* 初始化 `FieldArray` 的值,同时设置 `initialValue`
* @param values 要设置为初始化值的值
*/
initialize(values: Item[]) {
this.initialValue = Some(values);
const children = values.map(this._buildChild);
this.children$.next(children);
}
/**
* 添加一批元素到 `FieldArray` 的末尾
* @param models 待添加的 `Model` 对象
*/
push(...models: Child[]): number;
/**
* 添加一批元素到 `FieldArray` 的末尾
* @param values 待添加的值
*/
push(...values: Item[]): number;
push(...items: Item[] | Child[]) {
const nextChildren = this.children$.getValue().concat(
(items.map as any)((item: Item | Child) => {
return isModel(item)
? this._linkChild(item as Child)
: this._buildChild(item as Item);
})
);
this.children$.next(nextChildren);
// Same as `Array.prototype.push`
return nextChildren.length;
}
/**
* 删除 `FieldArray` 最后的一个元素。
* @return `Model` 对象,而不是 `Model` 对象上的值。
*/
pop() {
const children = this.children$.getValue().slice();
const child = children.pop();
child && this._disposeChild(child);
this.children$.next(children);
return child;
}
/**
* 删除 `FieldArray` 第一个元素
* @return `Model` 对象,而不是 `Model` 对象上的值。
*/
shift() {
const children = this.children$.getValue().slice();
const child = children.shift();
child && this._disposeChild(child);
this.children$.next(children);
return child;
}
/**
* 在 `FieldArray` 开头添加值
* @param models 待添加的 `Model` 对象
*/
unshift(...models: Child[]): number;
/**
* 在 `FieldArray` 开头添加值
* @param values 待添加的值
*/
unshift(...values: Item[]): number;
unshift(...items: Item[] | Child[]) {
const nextChildren = (items.map as any)((item: Item | Child) => {
return isModel(item)
? this._linkChild(item as Child)
: this._buildChild(item as Item);
}).concat(this.children$.getValue());
this.children$.next(nextChildren);
return nextChildren.length;
}
/**
* 在 `FieldArray` 的指定位置删除指定数量的元素,并添加指定的新元素
* @param start 开始删除的元素位置
* @param deleteCount 删除的元素个数
* @param models 待添加的 `Model`
* @return `Model` 对象,而不是 `Model` 对象上的值
*/
splice(
start: number,
deleteCount: number,
...models: readonly Child[]
): Child[];
/**
* 在 `FieldArray` 的指定位置删除指定数量的元素,并添加指定的新元素
* @param start 开始删除的元素位置
* @param deleteCount 删除的元素个数
* @param values 待添加的元素值
* @return `Model` 对象,而不是 `Model` 对象上的值
*/
splice(
start: number,
deleteCount: number,
...values: readonly Item[]
): Child[];
splice(
start: number,
deleteCount = 0,
...items: readonly Item[] | readonly Child[]
): Child[] {
const children = this.children$.getValue().slice();
const insertedChildren = (items.map as any)((item: Item | Child) => {
return isModel(item)
? this._linkChild(item as Child)
: this._buildChild(item as Item);
});
const removedChildren = children.splice(
start,
deleteCount,
...insertedChildren
);
removedChildren.forEach(this._disposeChild);
this.children$.next(children);
return removedChildren;
}
/**
* 仅保留 `predicate` 返回 `true` 的子 `Model`。用于按一定条件批量过滤,相比重复调用 `splice` 效率更高。
* 该方法直接操作当前的 `FieldArrayModel` 对象。
* @param predicate 每个子 `Model` 的 `predicate` 函数,返回 `true` 在结果中保留该 `Model`
* @return 当前 `FieldArrayModel` 对象
*/
filter(
predicate: (item: Child, index: number, array: Child[]) => boolean
): this {
const children = this.children$.getValue().filter((item, index, array) => {
const keep = predicate(item, index, array);
if (!keep) {
this._disposeChild(item);
}
return keep;
});
this.children$.next(children);
return this;
}
/**
* 对 `FieldArrayModel` 的子 `Model` 排序,该方法直接操作当前的 `FieldArrayModel` 对象。
* @param compareFn 比较函数,行为和 `Array.prototype.sort` 的比较函数一致
* @return 当前 `FieldArrayModel` 对象
*/
sort(compareFn: (a: Child, b: Child) => number): this {
const children = this.children$.getValue().slice().sort(compareFn);
this.children$.next(children);
return this;
}
/**
* 执行 `FieldArray` 的校验
* @param option 校验的参数
*/
validate(option = ValidateOption.Default): Promise<any> {
if (option & ValidateOption.IncludeChildrenRecursively) {
const childOption = option | ValidateOption.StopPropagation;
return Promise.all(
this.children$
.getValue()
.map(it => it.validate(childOption))
.concat(this.triggerValidate(option))
);
}
return this.triggerValidate(option);
}
/**
* 是否 `FieldArray` 所有元素都没有修改过
*/
pristine() {
const children = this.children$.getValue();
for (let i = 0; i < children.length; i += 1) {
const child = children[i];
if (child.dirty()) {
return false;
}
}
return true;
}
/**
* 是否 `FieldArray` 中任意元素有过修改
*
* `dirty === !pristine`
*/
dirty() {
return !this.pristine();
}
/**
* 是否 `FieldArray` 任意元素被 touch 过
*/
touched() {
const children = this.children$.getValue();
for (let i = 0; i < children.length; i += 1) {
const child = children[i];
if (child.touched()) {
return true;
}
}
return false;
}
dispose() {
super.dispose();
this.children.forEach(child => {
this._unsubscribeChild(child);
child.dispose();
});
this.children$.next([]);
// Close all subjects and setup sentinels to warn use after free errors
this.children$.complete();
this._value$?.complete();
this._valid$?.complete();
this._valid$ = createSentinelSubject(this._displayName, false);
this._value$ = createSentinelSubject(this._displayName, []);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
(this.children$ as BehaviorSubject<Child[]>) = createSentinelSubject(
this._displayName,
[]
);
}
private _linkChild(child: Child) {
child.owner = this;
return child;
}
private _initValue$() {
const value$ = new BehaviorSubject<readonly Item[]>(this.getRawValue());
this._value$ = value$;
/** Skip the first subscription to avoid setting initialValue repeatedly */
this.children$.pipe(skip(1)).subscribe(() => {
value$.next(this.getRawValue());
});
for (const child of this.children) {
this._subscribeChild(child);
}
/** Do it if there's no initialized observable */
if (!this._valid$) {
this._initUnsubscribeChild();
}
}
private _initValid$() {
const valid$ = new BehaviorSubject(isNil(this.error));
this._valid$ = valid$;
const $ = this.error$.subscribe(maybeError => {
const selfValid = isNil(maybeError);
valid$.next(selfValid && !this.invalidModels.size);
});
this.mapModelToSubscriptions.set(this, [$]);
/** Skip the first subscription to avoid setting initialValue repeatedly */
this.children$.pipe(skip(1)).subscribe(() => {
/** Emit valid$ when children removed */
valid$.next(isNil(this.error) && !this.invalidModels.size);
});
for (const child of this.children) {
this._subscribeChild(child);
}
/** Do it if there's no initialized observable */
if (!this._value$) {
this._initUnsubscribeChild();
}
}
/**
* Subscribe `children$` to unsubscribe the removed child
*/
private _initUnsubscribeChild() {
this.children$.pipe(pairwise()).subscribe(([prev, current]) => {
for (const child of prev) {
if (!current.includes(child)) {
this._unsubscribeChild(child);
}
}
});
}
/**
* Base method for getting value from array model
* @param getter map model to value
*/
private _getValue<V>(getter: (model: BasicModel<Item>) => V): V[] {
return this.children$.getValue().map(child => {
if (isModelRef<Item, this, BasicModel<Item>>(child)) {
const model = child.getModel();
return isModel<Item>(model)
? getter(model)
: (get(child.initialValue) as unknown as V);
} else if (isModel<Item>(child)) {
return getter(child);
}
throw createUnexpectedModelError(child);
});
}
/**
* Handle different types of the child
*/
private _subscribeChild(child: Child) {
const { _valid$, _value$, mapModelToSubscriptions } = this;
if (_valid$ || _value$) {
if (isModelRef<Item, FieldArrayModel<Item, Child>, Child>(child)) {
/** Subscribe current model immediately */
const model = child.getModel();
if (isModel<Item>(model)) {
this._subscribeChildModel(model);
}
/** Replace subscription while model updated */
const $ = child.model$.pipe(pairwise()).subscribe(([prev, current]) => {
prev && this._unsubscribeChild(prev);
if (isModel<Item>(current)) {
this._subscribeChildModel(current);
}
});
mapModelToSubscriptions.set(child, [$]);
} else if (isModel<Item>(child)) {
this._subscribeChildModel(child);
}
}
}
/**
* Subscribe `valid$` and `value$` of the child
* @param model
*/
private _subscribeChildModel(model: BasicModel<Item>) {
const { error$, _valid$, _value$, invalidModels } = this;
if (_valid$) {
this._subscribeObservable(model, model._getValid$(), valid => {
if (valid) {
invalidModels.delete(model);
} else {
invalidModels.add(model);
}
_valid$.next(!invalidModels.size && isNil(error$.value));
});
}
if (_value$) {
this._subscribeObservable(
model,
model._getValue$(),
childValue => {
const index = this.children.findIndex(it => {
if (
isModelRef<Item, FieldArrayModel<Item, Child>, BasicModel<Item>>(
it
)
) {
return it.getModel() === model;
} else if (isModel<Item>(it)) {
return it === model;
} else {
throw createUnexpectedModelError(it);
}
});
const copy = [..._value$.value];
copy.splice(index, 1, childValue);
_value$.next(copy);
},
true /** New value will be inserted in the observer of `children$`, skip the first subscription when inserting a new child */
);
}
}
/**
* Subscribe a specified observable of the model
* @param model as the key for mapping to subscription
* @param observable
* @param observer
* @param skipFirst skip the first subscription
*/
private _subscribeObservable<T>(
model: BasicModel<Item>,
observable: Observable<T>,
observer: (value: T) => void,
skipFirst?: boolean
) {
const { mapModelToSubscriptions } = this;
const $ = (skipFirst ? observable.pipe(skip(1)) : observable).subscribe(
observer
);
const subs = mapModelToSubscriptions.get(model);
if (subs) {
subs.push($);
} else {
mapModelToSubscriptions.set(model, [$]);
}
}
/**
* Unsubscribe `valid$` and `value$` of the model
*/
private _unsubscribeChild(child: Child) {
let model: BasicModel<Item> | null = null;
if (isModel<Item>(child)) {
this.invalidModels.delete(child);
model = child;
} else if (isModelRef<Item, this, BasicModel<Item>>(child)) {
model = child.getModel();
}
this._unsubscribeModel(child);
if (model) {
this._unsubscribeModel(model);
}
}
private _disposeChild = (child: Child) => {
this._unsubscribeChild(child);
child.owner = null;
};
private _buildChild = (child: Item) => {
const model = this.childFactory(child);
this._subscribeChild(model);
return model;
};
private _unsubscribeModel(model: IModel<Item>) {
const subs = this.mapModelToSubscriptions.get(model);
subs?.forEach(sub => sub.unsubscribe());
this.mapModelToSubscriptions.delete(model);
if (isModel<Item>(model)) {
this.invalidModels.delete(model);
}
}
}
FieldArrayModel.prototype[FIELD_ARRAY_ID] = true;
export { FieldArrayModel };