Issue
I am trying to make a directive which listens for DOM property changes
to automatically save a property value to LocalStorage, and restore it automatically on page load (to save user preferences on any HTML element). I need it to listen to input changes, but since I want a generic directive, I don't want to listen to the change
event.
I got a solution working using this answer: https://stackoverflow.com/a/55737231/7920723
I had to convert it to typescript and adapt it to use Observables
, here is a mvce:
import { AfterViewInit, Component, Directive, ElementRef, EventEmitter, Input, Output } from '@angular/core';
import { Observable } from 'rxjs';
@Directive({
selector: '[cdltStoreProperty]',
standalone: true
})
export class StoreAttributeDirective implements AfterViewInit {
id!: string;
@Input('cdltStoreProperty') attributeName: string | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@Output() readonly storedValue = new EventEmitter<any>();
constructor(
private ref: ElementRef
) {
console.log('test');
}
ngAfterViewInit(): void {
const element = this.ref.nativeElement;
if (this.attributeName) {
this.id = `${StoreAttributeDirective.name}_${element.getAttribute('id')}`;
console.log(`id = ${this.id}`);
const valueStored = this.restoreValue();
if (!valueStored) {
const value = element[this.attributeName];
this.saveValue(value);
this.storedValue.emit(value);
}
observePropertyChange(this.ref, this.attributeName).subscribe(newValue => {
console.log("property changed");
this.saveValue(newValue)
});
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
restoreValue() : any {
const valueStored = window.localStorage.getItem(this.id);
console.log(`valueStored = ${valueStored} of type = ${typeof valueStored}`);
if (valueStored && this.attributeName) {
const value = JSON.parse(valueStored);
console.log(`Restoring value ${value} of type ${typeof value}`);
this.ref.nativeElement[this.attributeName] = value;
return value;
}
return undefined;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
saveValue(value: any) {
console.log(`Saving ${value} of type ${typeof value}`);
const valueToStore = JSON.stringify(value);
console.log(`Saving value to store ${valueToStore} of type ${typeof valueToStore}`);
window.localStorage.setItem(this.id, valueToStore);
}
}
@Component({
selector: 'cdlt-mvce',
standalone: true,
imports: [StoreAttributeDirective],
template: `
<input
id="remember-me-checkbox"
type="checkbox"
cdltStoreProperty="checked"
[checked]="checkedValue"
(storedValue)="loadStoredValue($event)"
(change)="checkedValue = !checkedValue"
>`,
styleUrl: './mvce.component.scss'
})
export class MvceComponent {
checkedValue = true;
loadStoredValue(value: any) {
this.checkedValue = value;
}
}
function observePropertyChange(elementRef: ElementRef, propertyName: string) : Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const propertyObserver$ = new Observable<any>(observer => {
const superProps = Object.getPrototypeOf(elementRef.nativeElement);
const propertyDescriptor = Object.getOwnPropertyDescriptor(superProps, propertyName);
if (!propertyDescriptor) {
console.error(`No property descriptor for ${propertyName}`);
return;
}
const superGet = propertyDescriptor.get;
const superSet = propertyDescriptor.set;
if (!superGet) {
console.error(`No getter for ${propertyName}`);
return;
} else if (!superSet) {
console.error(`No setter for ${propertyName}`);
return;
}
const newProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: function() : any {
return superGet.apply(this, []);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
set: function (t : any) : any {
observer.next(t);
return superSet.apply(this, [t]);
}
};
Object.defineProperty(elementRef.nativeElement, propertyName, newProps);
});
return propertyObserver$;
}
Since I'm accessing properties I subscribe to the property during the AfterViewInit
phase, do you think this is correct ?
There are 2 problems with this code:
- If you refresh while the
input
is unchecked, the restore function raises anExpressionChangedAfterItHasBeenCheckedError
, so I guess this isn't the right way to do this - If you use an input with the property
checked
not bound, for example withchecked=false
, the observer breaks somewhere, for reasons I don't understand, as if thenativeElement
or thechecked
property were replaced after the view initialization.
Do you know what would be the correct way to achieve such a directive ?
Solution
As commented, I feel a better idea is to do this when a property changes on your viewmodel (or component) instance, it's fairly simple.
//
// Inspired by https://github.com/zhaosiyang/property-watch-decorator/blob/master/src/index.ts
//
// Annotate a field in a typescript class to store its' content in localstorage transparently.
//
export function Preference<T = any>(preferenceKey: string, defaultValueObj: T) {
return (target: any, key: PropertyKey) => {
Object.defineProperty(target, key, {
set: function(value) {
localStorage.setItem(preferenceKey, JSON.stringify(value));
},
get: function() {
const rawValue = window.localStorage.getItem(preferenceKey);
const value = rawValue !== null && rawValue !== undefined ? <T>JSON.parse(rawValue) : defaultValueObj;
return value;
},
});
};
}
you can then use it in your component:
class PreferencesComponent {
@Preference('TableView.itemsPerPage', 10)
itemsPerPage: number;
}
and bind it as normal:
<input type="number" [(ngModel)]="itemsPerPage" min="10" max="100">
Hope this helpse!
Answered By - Kris
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.