Created
March 20, 2024 21:55
-
-
Save marcj/36734a006cd8fb936143e6e56a9278b0 to your computer and use it in GitHub Desktop.
Deepkit Angular 2
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Deepkit Framework | |
* Copyright (C) 2021 Deepkit UG, Marc J. Schmidt | |
* | |
* This program is free software: you can redistribute it and/or modify | |
* it under the terms of the MIT License. | |
* | |
* You should have received a copy of the MIT License along with this program. | |
*/ | |
// copied from @deepkit/type-angular, as long as it doesnt compile | |
import { | |
AbstractControl, | |
AbstractControlOptions, | |
AsyncValidatorFn, | |
FormArray, | |
FormControl, | |
FormGroup, | |
ValidationErrors, | |
ValidatorFn | |
} from '@angular/forms'; | |
import { ClassType, isFunction } from '@deepkit/core'; | |
import { | |
getValidatorFunction, | |
ReflectionClass, | |
ReflectionKind, | |
Type, | |
ValidationErrorItem, | |
ValidatorError | |
} from '@deepkit/type'; | |
import { Subscription } from 'rxjs'; | |
type PropPath = string | (() => string); | |
function getPropPath(propPath?: PropPath, append?: string | number): string { | |
propPath = isFunction(propPath) ? propPath() : propPath; | |
if (propPath && append !== undefined) { | |
return propPath + '.' + append; | |
} | |
if (propPath) { | |
return propPath; | |
} | |
if (append !== undefined) { | |
return String(append); | |
} | |
return ''; | |
} | |
function createControl<T>( | |
propPath: PropPath, | |
propName: string, | |
prop: Type, | |
parent?: FormGroup | FormArray, | |
conditionalValidators: TypedFormGroupConditionalValidators<any, any> = {}, | |
limitControls: LimitControls<T> = {} | |
): AbstractControl { | |
const validator = (control: AbstractControl): ValidationErrors | null => { | |
const rootFormGroup = control.root as TypedFormGroup<any>; | |
if (!rootFormGroup.value) { | |
// not yet initialized | |
return null; | |
} | |
function errorsToAngularErrors(errors: ValidationErrorItem[]): any { | |
if (errors.length) { | |
const res: ValidationErrors = {}; | |
for (const e of errors) { | |
res[e.code] = e.message; | |
} | |
return res; | |
} | |
return null; | |
} | |
const errors: ValidationErrorItem[] = []; | |
const val = conditionalValidators[propName]; | |
const fn = getValidatorFunction(undefined, prop); | |
(fn as any)(control.value, { errors }, getPropPath(propPath)); | |
return errorsToAngularErrors(errors); | |
}; | |
let control: AbstractControl; | |
if (prop.kind === ReflectionKind.array) { | |
conditionalValidators['0'] = conditionalValidators[propName]; | |
control = new TypedFormArray(propPath, propName, prop.type, limitControls, conditionalValidators); | |
} else { | |
if (prop.kind === ReflectionKind.class) { | |
const t = propName ? conditionalValidators[propName] : conditionalValidators; | |
const conditionalValidatorsForProp = isConditionalValidatorFn(t) ? {} : t; | |
control = TypedFormGroup.fromEntityClass(prop.classType, limitControls, undefined, conditionalValidatorsForProp, propPath); | |
} else { | |
control = new FormControl(undefined, validator); | |
} | |
} | |
if (parent && conditionalValidators[propName]) { | |
parent.root.valueChanges.subscribe((v) => { | |
// todo: rework to apply validity status sync. find our why here is a race condition. | |
setTimeout(() => { | |
control.updateValueAndValidity({ emitEvent: false }); | |
}); | |
}); | |
} | |
if (parent) { | |
control.setParent(parent); | |
} | |
return control; | |
} | |
type FlattenIfArray<T> = T extends Array<any> ? T[0] : T; | |
type ValidatorType = ((value: any) => ValidatorError | void); | |
type ConditionalValidatorFn<RT, PT> = (rootValue: RT, parentValue: PT) => ValidatorType | ValidatorType[] | void | undefined; | |
function isConditionalValidatorFn(obj: any): obj is ConditionalValidatorFn<any, any> { | |
return isFunction(obj); | |
} | |
type TypedFormGroupConditionalValidators<RT, T> = { | |
[P in keyof T & string]?: ConditionalValidatorFn<RT, T> | (FlattenIfArray<T[P]> extends object ? TypedFormGroupConditionalValidators<RT, FlattenIfArray<T[P]>> : undefined); | |
}; | |
interface TypedAbstractControl<T> extends AbstractControl { | |
value: T; | |
} | |
type TypedControl<T> = T extends Array<any> ? TypedFormArray<FlattenIfArray<T>> : (T extends object ? TypedFormGroup<T> : TypedAbstractControl<T>); | |
type Controls<T> = { [P in keyof T & string]: TypedControl<T[P]> }; | |
type LimitControls<T> = { | |
[P in keyof T & string]?: 1 | (FlattenIfArray<T[P]> extends object ? LimitControls<FlattenIfArray<T[P]>> : 1) | |
}; | |
export interface TypedFormArray<T> { | |
value: T[]; | |
} | |
export class TypedFormArray<T> extends FormArray { | |
_value: T[] = []; | |
constructor( | |
private propPath: PropPath, | |
private propName: string, | |
private prop: Type, | |
private limitControls: LimitControls<T> = {}, | |
private conditionalValidators: TypedFormGroupConditionalValidators<any, any> = {} | |
) { | |
super([], []); | |
Object.defineProperty(this, 'value', { | |
get(): any { | |
return this._value; | |
}, | |
set(v: T[]): void { | |
if (this._value) { | |
this._value.length = 0; | |
Array.prototype.push.apply(this._value, v); | |
} else { | |
this._value = v; | |
} | |
} | |
}); | |
} | |
get typedControls(): TypedControl<T>[] { | |
return this.controls as any; | |
} | |
protected createControl(value?: T): AbstractControl { | |
const prop = { ...this.prop }; | |
let control: AbstractControl; | |
control = createControl(() => getPropPath(this.propPath, this.controls.indexOf(control)), this.propName, prop, this, this.conditionalValidators, this.limitControls); | |
(control.value as any) = value; | |
return control; | |
} | |
addItem(item: T): void { | |
this.push(this.createControl(item)); | |
} | |
setRefValue(v?: T[]): void { | |
this._value = v || []; | |
this.setValue(this._value); | |
} | |
removeItem(item: T): void { | |
const index = this.value.indexOf(item); | |
if (index !== -1) { | |
this.controls.splice(index, 1); | |
this.value.splice(index, 1); | |
} | |
} | |
removeItemAtIndex(item: T, index: number): void { | |
if (index !== -1 && this.controls[index]) { | |
this.controls.splice(index, 1); | |
this.value.splice(index, 1); | |
} | |
} | |
push(control?: AbstractControl): void { | |
super.push(control || this.createControl()); | |
} | |
setValue(value: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }): void { | |
// note: this.push modifies the ref `value`, so we need to (shallow) copy the | |
// array (not the content) and reassign the content (by not changing the | |
// array ref) later. | |
const copy = value.slice(0); | |
this.clear(); | |
for (const item of copy) { | |
this.push(this.createControl(item)); | |
} | |
// here the value is empty, but we make sure to remove any content, | |
// and reassign from our copied array. | |
Array.prototype.splice.call(value, 0, value.length, ...copy); | |
if (value.push === Array.prototype.push) { | |
(value as any).push = (...args: any[]) => { | |
Array.prototype.push.apply(value, args); | |
for (const item of args) { | |
this.push(this.createControl(item)); | |
} | |
}; | |
(value as any).splice = (...args: any) => { | |
Array.prototype.splice.apply(value, args); | |
this.setValue(value); | |
}; | |
} | |
super.setValue(value, options); | |
} | |
rerender() { | |
this.clear(); | |
for (const item of this._value.slice(0)) { | |
this.push(this.createControl(item)); | |
} | |
} | |
printDeepErrors(path?: string): void { | |
for (const control of this.controls) { | |
if (control instanceof TypedFormGroup || control instanceof TypedFormArray) { | |
control.printDeepErrors(getPropPath(path, this.controls.indexOf(control))); | |
} else if (control.invalid) { | |
console.log('invalid', getPropPath(path, this.controls.indexOf(control)), control.errors, control.value, control.status, control.value, control); | |
} | |
} | |
} | |
} | |
export class TypedFormGroup<T extends object> extends FormGroup { | |
public classType?: ClassType<T>; | |
public typedValue?: T; | |
protected lastSyncSub?: Subscription; | |
public value: T | undefined; | |
getValue(): T { | |
if (!this.value) throw new Error('No value set yet. use init() or syncEntity() first.') | |
return this.value; | |
} | |
get typedControls(): Controls<T> { | |
return this.controls as any; | |
} | |
constructor(controls: { [p: string]: AbstractControl }, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null) { | |
super(controls, validatorOrOpts, asyncValidator); | |
let inSetting = false; | |
Object.defineProperty(this, 'value', { | |
get(): any { | |
return this.typedValue; | |
}, | |
set(v: T[]): void { | |
if (inSetting) return; | |
inSetting = true; | |
if (this.classType) { | |
if (this.typedValue === v) return; | |
if (!this.typedValue || this.typedValue !== v) { | |
// is needed since angular wont set `this.value` to `value`, but it simply iterates. | |
// we need however the actual reference. | |
this.typedValue = v; | |
} | |
if (this.lastSyncSub) { | |
this.lastSyncSub.unsubscribe(); | |
} | |
for (const [name, control] of Object.entries(this.controls)) { | |
// const control = this.controls[i as keyof T & string]; | |
if (control instanceof TypedFormArray) { | |
control.setRefValue((v as any)[name]); | |
} else { | |
(control as any).setValue((v as any)[name]); | |
} | |
} | |
// this comes after `setValue` so we don't get old values | |
this.lastSyncSub = this.valueChanges.subscribe(() => { | |
this.updateEntity(v); | |
this.updateValueAndValidity({ emitEvent: false }); | |
}); | |
this.updateValueAndValidity(); | |
} else { | |
// angular tries to set via _updateValue() `this.value` again using `{}`, which we simply ignore. | |
// except when its resetted | |
if (v === undefined) { | |
this.typedValue = undefined; | |
this.updateValueAndValidity(); | |
} | |
} | |
inSetting = false; | |
} | |
}); | |
} | |
static fromEntityClass<T extends object>( | |
classType: ClassType<T>, | |
limitControls: LimitControls<T> = {}, | |
validation?: (control: TypedFormGroup<T>) => ValidationErrors | null, | |
conditionalValidators: TypedFormGroupConditionalValidators<T, T> = {}, | |
path?: PropPath | |
): TypedFormGroup<T> { | |
const entitySchema = ReflectionClass.from(classType); | |
const t = new TypedFormGroup<T>({}, validation as ValidatorFn); | |
t.classType = classType; | |
const validNames = Object.keys(limitControls); | |
for (const prop of entitySchema.getProperties()) { | |
if (validNames.length && !validNames.includes(prop.name)) { | |
continue; | |
} | |
const limitControlsForProp = limitControls[prop.name as keyof T & string] === 1 ? {} : limitControls[prop.name as keyof T & string]; | |
t.registerControl(prop.name, createControl(() => getPropPath(path, prop.name), prop.getNameAsString(), prop.type, t, conditionalValidators, limitControlsForProp)); | |
} | |
return t; | |
} | |
updateValueAndValidity(opts?: { onlySelf?: boolean; emitEvent?: boolean }): void { | |
super.updateValueAndValidity(opts); | |
if (this.validator && !this.value && !this.disabled) { | |
// we have no valid, so our validator decides whether its valid or not | |
(this.status as any) = this.validator(this) ? 'INVALID' : 'VALID'; | |
} | |
} | |
init(value?: T): this { | |
this.reset(value); | |
return this; | |
} | |
reset(value?: any, options?: { onlySelf?: boolean; emitEvent?: boolean }): void { | |
if (value && this.classType && value instanceof this.classType) { | |
this.syncEntity(value); | |
} | |
// super.reset(value, options); | |
this.markAsPending(); | |
for (const control of Object.values(this.controls)) { | |
control.markAsPending(); | |
} | |
} | |
setValue(value: { [p: string]: any }, options?: { onlySelf?: boolean; emitEvent?: boolean }): void { | |
this.value = value as any; | |
} | |
getControls(): Controls<any> { | |
return this.controls as Controls<any>; | |
} | |
printDeepErrors(path?: string): void { | |
for (const [name, control] of Object.entries(this.controls)) { | |
if (control instanceof TypedFormGroup || control instanceof TypedFormArray) { | |
control.printDeepErrors(getPropPath(path, name)); | |
} else if (control.invalid) { | |
console.log('invalid', getPropPath(path, name), control.errors, control.value, control.status, control.value, control); | |
} | |
} | |
} | |
/** | |
* Sets the current form values from the given entity and syncs changes automatically back to the entity. | |
*/ | |
public syncEntity(entity: T): void { | |
this.value = entity; | |
} | |
/** | |
* Saves the current values from this form into the given entity. | |
*/ | |
public updateEntity(entity: T): void { | |
for (const [name, c] of Object.entries(this.controls)) { | |
if (c.touched || c.dirty) { | |
if (c instanceof TypedFormGroup) { | |
if ((entity as any)[name]) { | |
c.updateEntity((entity as any)[name]); | |
} | |
} else { | |
(entity as any)[name] = c.value; | |
} | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment