Last active
March 20, 2024 21:52
-
-
Save marcj/f5be5dea0c12de8c7d5d33787c06f532 to your computer and use it in GitHub Desktop.
Deepkit Angular form
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
export class OrderFilter { | |
order: string = ''; | |
customer: string = ''; | |
article: string = ''; | |
} | |
@Component({ | |
template: ` | |
<form [formGroup]="form"> | |
<dui-input round clearer lightFocus formControlName="order" placeholder="Vorgang"></dui-input> | |
<dui-input round clearer lightFocus formControlName="customer" placeholder="Kunde"></dui-input> | |
<dui-input round clearer lightFocus formControlName="article" placeholder="Artikel"></dui-input> | |
</form> | |
` | |
}) | |
export class InvoicesComponent implements OnInit { | |
form = TypedFormGroup.fromEntityClass(OrderFilter).init(new OrderFilter); | |
} |
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
import { | |
AbstractControl, | |
ControlValueAccessor, | |
FormArray, | |
FormControl, | |
FormControlOptions, | |
FormControlState, | |
FormGroup, | |
NG_VALUE_ACCESSOR, | |
NgControl, | |
ValidationErrors, | |
ValidatorFn | |
} from "@angular/forms"; | |
import { ClassType, isClass, isFunction, nextTick } from "@deepkit/core"; | |
import { | |
deserialize, | |
getValidatorFunction, | |
hasDefaultValue, | |
isCustomTypeClass, | |
isOptional, | |
ReceiveType, | |
ReflectionClass, | |
ReflectionKind, | |
resolveReceiveType, | |
serialize, | |
Type, | |
typeSettings, | |
UnpopulatedCheck, | |
validationAnnotation, | |
ValidationErrorItem | |
} from "@deepkit/type"; | |
import { ChangeDetectorRef, Directive, EventEmitter, forwardRef, HostBinding, Inject, Injector, Input, OnDestroy, Output, SkipSelf, Type as AngularType } from "@angular/core"; | |
type PropPath = string | (() => string); | |
function getLastProp(propPath?: PropPath): string { | |
propPath = isFunction(propPath) ? propPath() : propPath; | |
if (!propPath) return ''; | |
return propPath.split('.').pop() || ''; | |
} | |
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 isRequired(type: Type) { | |
const val = validationAnnotation.getFirst(type); | |
if (val && val.name === 'minLength') { | |
return true; | |
} | |
if (type.parent && (type.parent.kind === ReflectionKind.property || type.parent.kind === ReflectionKind.propertySignature)) { | |
return !isOptional(type.parent) && !hasDefaultValue(type.parent); | |
} | |
return !isOptional(type); | |
} | |
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; | |
} | |
/** | |
* Provides the value accessor so that the value is read from the model's parent or itself if not parent is given. | |
*/ | |
function patchValue(t: any, value: any, propPath: PropPath) { | |
Object.defineProperty(t, 'value', { | |
get: () => { | |
if (t.parent) { | |
return t.parent.value ? t.parent.value[getLastProp(propPath)] : undefined; | |
} | |
return value; | |
}, | |
set: (v: any) => { | |
if (t.parent) { | |
if (t.parent.value) { | |
t.parent.value[getLastProp(propPath)] = v; | |
} | |
} | |
value = v; | |
} | |
}); | |
} | |
export class TypedFormControl<T = any> extends FormControl { | |
deepkitErrors?: ValidationErrorItem[]; | |
constructor(public propPath: PropPath, public type: Type, value: FormControlState<T> | T, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null) { | |
super(value, validatorOrOpts); | |
patchValue(this, value, propPath); | |
} | |
setValue(value: any, options?: { onlySelf?: boolean; emitEvent?: boolean; emitModelToViewChange?: boolean; emitViewToModelChange?: boolean }) { | |
console.log('setValue', getPropPath(this.propPath), value, options); | |
super.setValue(value, options); | |
} | |
isRequired() { | |
return isRequired(this.type); | |
} | |
} | |
type WithDeepkitErrors = { deepkitErrors?: ValidationErrorItem[] }; | |
function isFunctionType(type: Type): boolean { | |
return type.kind === ReflectionKind.method || type.kind === ReflectionKind.methodSignature || type.kind === ReflectionKind.function; | |
} | |
function createControl<T>( | |
propPath: PropPath, | |
propName: string, | |
prop: Type, | |
parent?: FormGroup | FormArray, | |
): AbstractControl { | |
const type = prop.kind === ReflectionKind.property || prop.kind === ReflectionKind.propertySignature ? prop.type : prop; | |
let control: AbstractControl & WithDeepkitErrors; | |
const validator = (control: AbstractControl & WithDeepkitErrors): ValidationErrors | null => { | |
const rootFormGroup = control.root as TypedFormGroup<any>; | |
if (!rootFormGroup.value) { | |
// not yet initialized | |
return null; | |
} | |
let parent = control.parent; | |
while (parent) { | |
if (parent instanceof TypedFormGroup) { | |
//null/undefined values are handled by the parent | |
if (!parent.value) { | |
return null; | |
} | |
} | |
parent = parent.parent; | |
} | |
const errors: ValidationErrorItem[] = []; | |
if (prop && (prop.kind === ReflectionKind.property || prop.kind === ReflectionKind.propertySignature)) { | |
if (!control.value) { | |
if (!isRequired(prop)) { | |
return null; | |
} | |
} | |
if (type.kind === ReflectionKind.class && isCustomTypeClass(type)) { | |
return null; //handled in sub controls | |
} | |
} | |
const fn = getValidatorFunction(undefined, prop); | |
control.deepkitErrors = errors; | |
(fn as any)(control.value, { errors }, getPropPath(propPath)); | |
return errorsToAngularErrors(errors); | |
}; | |
// if (type.kind === ReflectionKind.class && isCustomTypeClass(type)) { | |
// control = TypedFormGroup.fromEntityClass(type, undefined, propPath); | |
// } else { | |
control = new TypedFormControl(propPath, prop, undefined, validator); | |
// } | |
if (parent) { | |
control.setParent(parent); | |
} | |
return control; | |
} | |
export class TypedFormGroup<T extends object, TRawValue extends T = T> extends FormGroup { | |
public value!: T; | |
deepkitErrors?: ValidationErrorItem[]; | |
protected reflection: ReflectionClass<any>; | |
constructor(public propPath: PropPath, public type: Type, value: FormControlState<T> | T, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null) { | |
super({}, validatorOrOpts); | |
this.reflection = ReflectionClass.from(type); | |
patchValue(this, value, propPath); | |
} | |
contains(controlName: string): boolean { | |
return super.contains(controlName); | |
} | |
registerOnChange(fn: (value: T) => void): void { | |
// super.registerOnChange(fn); | |
} | |
registerOnDisabledChange(fn: (isDisabled: boolean) => void): void { | |
// super.registerOnDisabledChange(fn); | |
} | |
init(value?: T): this { | |
this.setValue(value); | |
return this; | |
// const old = typeSettings.unpopulatedCheck; | |
// typeSettings.unpopulatedCheck = UnpopulatedCheck.None; | |
// try { | |
// this.reset(value); | |
// if (value) this.setValue(value); | |
// return this; | |
// } finally { | |
// typeSettings.unpopulatedCheck = old; | |
// } | |
} | |
_updateValue(): void { | |
//angular forms works normally in the way that controls updates the value, | |
//but we change that. The real values change controls. | |
} | |
getDeepkitErrors() { | |
return this.getAllDeepkitErrors().map(v => v.path + ': ' + v.message).join(', '); | |
} | |
getAllDeepkitErrors() { | |
//go through all controls and collect errors | |
const errors: ValidationErrorItem[] = []; | |
for (const control of Object.values(this.controls)) { | |
if (control instanceof TypedFormGroup) { | |
if (control.deepkitErrors) { | |
errors.push(...control.deepkitErrors); | |
} | |
} else if (control instanceof TypedFormControl) { | |
if (control.deepkitErrors) { | |
errors.push(...control.deepkitErrors); | |
} | |
} | |
} | |
return errors; | |
} | |
isRequired() { | |
return isRequired(this.type); | |
} | |
reset(value?: any) { | |
} | |
get(props: string | string[]): AbstractControl | null { | |
props = Array.isArray(props) ? props : props.split('.'); | |
const first = props.shift(); | |
if (!first) return null; | |
if (first && this.reflection.hasProperty(first)) { | |
const property = this.reflection.getProperty(first); | |
this.controls[first] = createControl(() => getPropPath(this.propPath, property.name), property.name, property.property, this); | |
} | |
let current: AbstractControl | null = this.controls[first]; | |
for (const prop of props) { | |
if (!current) return null; | |
current = current.get(prop); | |
} | |
if (current) return current; | |
return null | |
} | |
setValue(value: any, options: { onlySelf?: boolean; emitEvent?: boolean } = {}) { | |
console.log('setValue', getPropPath(this.propPath), value); | |
const old = typeSettings.unpopulatedCheck; | |
typeSettings.unpopulatedCheck = UnpopulatedCheck.None; | |
try { | |
this.value = value; | |
if (value) { | |
for (const [name, control] of Object.entries(this.controls)) { | |
// (this.value as any)[name] = (value as any)[name]; | |
control.setValue((value as any)[name], { onlySelf: true, emitEvent: options.emitEvent }); | |
} | |
} | |
} finally { | |
typeSettings.unpopulatedCheck = old; | |
} | |
// (this as any).value = value; | |
// if (value) { | |
// const o: any = {}; | |
// const set = (target: any, prop: ReflectionProperty, newValue: any, d?: PropertyDescriptor) => { | |
// // if (d && d.set) { | |
// // d.set(newValue); | |
// // } else { | |
// o[prop.name] = newValue; | |
// // } | |
// if (prop.type.kind === ReflectionKind.union) { | |
// //figure out the type and see if it changed. | |
// //if so, change the control if we need to (from object to array, or array to primitive, etc) | |
// } | |
// | |
// if (prop.isOptional() && newValue === undefined) { | |
// //remove control | |
// this.controls[prop.name].disable(); | |
// } else { | |
// this.controls[prop.name].enable(); | |
// } | |
// } | |
// Object.assign(o, value); | |
// for (const prop of this.reflection.getProperties()) { | |
// if (isFunctionType(prop.type)) continue; | |
// const d = Object.getOwnPropertyDescriptor(value, prop.name); | |
// Object.defineProperty(value, prop.name, { | |
// configurable: true, | |
// set: (newValue: any) => set(value, prop, newValue, d), | |
// get: () => (o as any)[prop.name], | |
// }); | |
// } | |
// (this as any).value = value; | |
// for (const property of this.reflection.getProperties()) { | |
// if (!this.controls[property.name]) return; | |
// if (property.isOptional() && (value as any)[property.name] === undefined) return; | |
// | |
// this.controls[property.name].setValue((value as any)[property.name], { onlySelf: true, emitEvent: options.emitEvent }); | |
// } | |
// console.log('setValue', value, this.value, o); | |
// } else { | |
// (this as any).value = undefined; | |
// } | |
this.updateValueAndValidity(options); | |
} | |
storeLocalStorage(key: string): this { | |
this.valueChanges.subscribe(() => { | |
const v = this.value; | |
if (!v) return; | |
localStorage.setItem(key, JSON.stringify(serialize(v, undefined, undefined, undefined, this.type))); | |
}); | |
const ch = localStorage.getItem(key); | |
if (ch) { | |
this.markAsDirty(); | |
const loaded: any = deserialize(JSON.parse(ch), undefined, undefined, undefined, this.type); | |
this.setValue(loaded); | |
} | |
return this; | |
} | |
static fromEntityClass<T extends object>( | |
type?: ClassType<T> | Type | ReceiveType<T>, | |
value?: T, | |
path?: PropPath, | |
): TypedFormGroup<T> { | |
type = isClass(type) ? ReflectionClass.from(type).type : resolveReceiveType(type); | |
const instance = new TypedFormGroup<T>(path || '', type, {} as any); | |
if (value) instance.init(value); | |
return instance; | |
} | |
} | |
export function ngValueAccessor<T>(clazz: AngularType<T>) { | |
return { | |
provide: NG_VALUE_ACCESSOR, | |
useExisting: forwardRef(() => clazz), | |
multi: true | |
}; | |
} | |
/** | |
* If you sub class this class and have own constructor or property initialization you need | |
* to provide the dependencies of this class manually. | |
* | |
* | |
constructor( | |
protected injector: Injector, | |
protected cd: ChangeDetectorRef, | |
@SkipSelf() protected cdParent: ChangeDetectorRef, | |
) { | |
super(injector, cd, cdParent); | |
} | |
* | |
*/ | |
@Directive() | |
export class ValueAccessorBase<T> implements ControlValueAccessor, OnDestroy { | |
/** | |
* @hidden | |
*/ | |
private _innerValue: T | undefined; | |
/** | |
* @hidden | |
*/ | |
public readonly _changedCallback: ((value: T | undefined) => void)[] = []; | |
/** | |
* @hidden | |
*/ | |
public readonly _touchedCallback: (() => void)[] = []; | |
private _ngControl?: NgControl; | |
private _ngControlFetched = false; | |
@Input() disabled?: boolean | ''; | |
@HostBinding('class.disabled') | |
get isDisabled(): boolean { | |
if (undefined === this.disabled && this.ngControl) { | |
return !!this.ngControl.disabled; | |
} | |
return this.disabled !== false && this.disabled !== undefined; | |
} | |
@Input() valid?: boolean; | |
@HostBinding('class.valid') | |
get isValid() { | |
return this.valid === true; | |
} | |
@Input() error?: boolean; | |
@HostBinding('class.error') | |
get isError() { | |
if (undefined === this.error && this.ngControl) { | |
return (this.ngControl.dirty || this.ngControl.touched) && this.ngControl.invalid; | |
} | |
return this.error; | |
} | |
@HostBinding('class.required') | |
@Input() | |
required: boolean | '' = false; | |
@Output() | |
public readonly change = new EventEmitter<T>(); | |
constructor( | |
@Inject(Injector) protected readonly injector: Injector, | |
@Inject(ChangeDetectorRef) public readonly cd: ChangeDetectorRef, | |
@Inject(ChangeDetectorRef) @SkipSelf() public readonly cdParent: ChangeDetectorRef, | |
) { | |
} | |
get ngControl(): NgControl | undefined { | |
if (!this._ngControlFetched) { | |
try { | |
this._ngControl = this.injector.get(NgControl); | |
} catch (e) { | |
} | |
this._ngControlFetched = true; | |
} | |
return this._ngControl; | |
} | |
/** | |
* @hidden | |
*/ | |
setDisabledState(isDisabled: boolean): void { | |
this.disabled = isDisabled; | |
} | |
/** | |
* @hidden | |
*/ | |
ngOnDestroy(): void { | |
} | |
/** | |
* @hidden | |
*/ | |
get innerValue(): T | undefined { | |
return this._innerValue; | |
} | |
/** | |
* Sets the internal value and signals Angular's form and other users (that subscribed via registerOnChange()) | |
* that a change happened. | |
* | |
* @hidden | |
*/ | |
set innerValue(value: T | undefined) { | |
if (this._innerValue !== value) { | |
this._innerValue = value; | |
for (const callback of this._changedCallback) { | |
callback(value); | |
} | |
this.onInnerValueChange().then(() => { | |
nextTick(() => this.cd); | |
}); | |
this.change.emit(value); | |
nextTick(() => this.cd); | |
} | |
} | |
/** | |
* Internal note: This method is called from outside. Either from Angular's form or other users. | |
* | |
* @hidden | |
*/ | |
writeValue(value?: T) { | |
if (this._innerValue !== value) { | |
this._innerValue = value; | |
} | |
nextTick(() => this.cd); | |
} | |
/** | |
* This method can be overwritten to get easily notified when innerValue has been changed, either | |
* by outside or inside. | |
* | |
* @hidden | |
*/ | |
async onInnerValueChange() { | |
} | |
/** | |
* Call this method to signal Angular's form or other users that this widget has been touched. | |
* @hidden | |
*/ | |
touch() { | |
for (const callback of this._touchedCallback) { | |
callback(); | |
} | |
} | |
/** | |
* @hidden | |
*/ | |
registerOnChange(fn: (value: T | undefined) => void) { | |
this._changedCallback.push(fn); | |
} | |
/** | |
* @hidden | |
*/ | |
registerOnTouched(fn: () => void) { | |
this._touchedCallback.push(fn); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment