Skip to content

Instantly share code, notes, and snippets.

@marcj
Last active March 20, 2024 21:52
Show Gist options
  • Save marcj/f5be5dea0c12de8c7d5d33787c06f532 to your computer and use it in GitHub Desktop.
Save marcj/f5be5dea0c12de8c7d5d33787c06f532 to your computer and use it in GitHub Desktop.
Deepkit Angular form
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);
}
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