Skip to content

Commit 164eee1

Browse files
committed
feat(core): Added valueChanges to controls and group
This introduces a valueChanges observable both on QueryParamGroup and QueryParamControl which emits at appropriate times. Furthermore, updates are now sync'ed across the group so we can emit once an update rather than many times. This will be necessary groundwork for #2. relates #2 fixes #16
1 parent f0350cd commit 164eee1

5 files changed

Lines changed: 168 additions & 45 deletions

File tree

projects/ngqp-demo/src/app/playground/playground.component.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
<ng-container [queryParamGroup]="paramGroup">
22
<input type="text" class="form-control" placeholder="Search" queryParamName="searchText" />
33

4+
<button type="button" (click)="setSearchTextValue('bar')">Set 'bar' with setValue</button>
5+
<button type="button" (click)="setSearchTextRoute('bar')">Set 'bar' with router</button>
6+
7+
<br />
8+
49
<input type="checkbox" class="form-control" queryParamName="checker" />
510

611
<input type="number" class="form-control" queryParamName="counter" />

projects/ngqp-demo/src/app/playground/playground.component.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Component } from '@angular/core';
2+
import { ActivatedRoute, Router } from '@angular/router';
23
import { QueryParamBuilder, QueryParamGroup } from '@ngqp/core';
34

45
@Component({
@@ -8,11 +9,15 @@ export class PlaygroundComponent {
89

910
public paramGroup: QueryParamGroup;
1011

11-
constructor(private queryParamBuilder: QueryParamBuilder) {
12+
constructor(
13+
private queryParamBuilder: QueryParamBuilder,
14+
private router: Router,
15+
private route: ActivatedRoute,
16+
) {
1217
this.paramGroup = queryParamBuilder.group({
1318
searchText: queryParamBuilder.param({
1419
name: 'q',
15-
debounceTime: 250,
20+
debounceTime: 1000,
1621
emptyOn: 'foo'
1722
}),
1823
checker: queryParamBuilder.booleanParam({
@@ -28,6 +33,26 @@ export class PlaygroundComponent {
2833
emptyOn: 2,
2934
}),
3035
});
36+
37+
this.paramGroup.valueChanges
38+
.subscribe(value => console.log('group', { paramGroup: value }));
39+
40+
// this.paramGroup.get('searchText').valueChanges
41+
// .subscribe(value => console.log('searchText', { searchText: value }));
42+
}
43+
44+
public setSearchTextValue(value: string) {
45+
this.paramGroup.get('searchText').setValue(value);
46+
}
47+
48+
public setSearchTextRoute(value: string) {
49+
this.router.navigate([], {
50+
relativeTo: this.route,
51+
queryParamsHandling: 'merge',
52+
queryParams: {
53+
q: value,
54+
}
55+
});
3156
}
3257

3358
}

projects/ngqp/core/src/lib/model.ts

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Comparator, isFunction, wrapTryCatch } from './util';
1+
import { Observable, Subject } from 'rxjs';
2+
import { Comparator, isFunction, isMissing, wrapTryCatch } from './util';
23
import { createEmptyOnDeserializer, createEmptyOnSerializer } from './serializers';
34

45
/** TODO Documentation */
@@ -7,6 +8,9 @@ export type ParamSerializer<T> = (model: T | null) => string | null;
78
/** TODO Documentation */
89
export type ParamDeserializer<T> = (value: string | null) => T | null;
910

11+
/** TODO Documentation */
12+
export type OnChangeFunction<T> = (value: T) => void;
13+
1014
/**
1115
* TODO Documentation
1216
*/
@@ -30,24 +34,58 @@ export interface QueryParamControlOpts<T> {
3034
*/
3135
export class QueryParamGroup {
3236

33-
private readonly controls: { [ controlName: string ]: QueryParamControl<any> };
37+
private _valueChanges = new Subject<any>();
38+
39+
/** TODO Documentation */
40+
public readonly valueChanges: Observable<any> = this._valueChanges.asObservable();
41+
42+
/** TODO Documentation */
43+
public readonly controls: { [ controlName: string ]: QueryParamControl<any> };
3444

35-
constructor(controls: {[controlName: string]: QueryParamControl<any>}) {
45+
constructor(controls: { [ controlName: string ]: QueryParamControl<any> }) {
3646
this.controls = controls;
47+
Object.values(this.controls).forEach(control => control.setParent(this));
3748
}
3849

3950
/** TODO Documentation */
4051
public get(controlName: string): QueryParamControl<any> {
4152
return this.controls[ controlName ];
4253
}
4354

55+
/** TODO Documentation */
56+
public get value(): any {
57+
const value: any = {};
58+
Object.keys(this.controls).forEach(controlName => value[ controlName ] = this.controls[ controlName ].value);
59+
60+
return value;
61+
}
62+
63+
/**
64+
* TODO Documentation
65+
*/
66+
public updateValue(opts: {
67+
emitEvent?: boolean,
68+
} = {}): void {
69+
if (opts.emitEvent !== false) {
70+
this._valueChanges.next(this.value);
71+
}
72+
}
73+
4474
}
4575

4676
/**
4777
* TODO Documentation
4878
*/
4979
export class QueryParamControl<T> {
5080

81+
private _valueChanges = new Subject<T>();
82+
83+
/** TODO Documentation */
84+
public readonly valueChanges: Observable<T> = this._valueChanges.asObservable();
85+
86+
/** TODO Documentation */
87+
public value: T = null;
88+
5189
/** TODO Documentation See QueryParamControlOpts */
5290
public name: string | null;
5391

@@ -63,8 +101,8 @@ export class QueryParamControl<T> {
63101
/** TODO Documentation See QueryParamControlOpts */
64102
public debounceTime: number | null;
65103

66-
/** TODO Documentation */
67-
public value: T = null;
104+
private parent: QueryParamGroup;
105+
private changeFunctions: OnChangeFunction<T>[] = [];
68106

69107
constructor(opts: QueryParamControlOpts<T>) {
70108
const { name, serialize, deserialize, debounceTime, emptyOn, compareWith } = opts;
@@ -94,4 +132,43 @@ export class QueryParamControl<T> {
94132
this.debounceTime = debounceTime;
95133
}
96134

135+
/**
136+
* TODO Documentation
137+
* @internal
138+
*/
139+
public registerOnChange(fn: OnChangeFunction<T>): void {
140+
this.changeFunctions.push(fn);
141+
}
142+
143+
/**
144+
* TODO Documentation
145+
*/
146+
public setValue(value: T | null): void {
147+
this.changeFunctions.forEach(changeFn => changeFn(value));
148+
}
149+
150+
/**
151+
* TODO Documentation
152+
*/
153+
public updateValue(opts: {
154+
emitEvent?: boolean,
155+
onlySelf?: boolean,
156+
} = {}): void {
157+
if (opts.emitEvent !== false) {
158+
this._valueChanges.next(this.value);
159+
}
160+
161+
if (!isMissing(this.parent) && !opts.onlySelf) {
162+
this.parent.updateValue(opts);
163+
}
164+
}
165+
166+
/**
167+
* TODO Documentation
168+
* @internal
169+
*/
170+
public setParent(parent: QueryParamGroup): void {
171+
this.parent = parent;
172+
}
173+
97174
}
Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Directive, Input, OnDestroy } from '@angular/core';
1+
import { Directive, Input, OnDestroy, OnInit } from '@angular/core';
22
import { ActivatedRoute, Params, Router } from '@angular/router';
33
import { Subject } from 'rxjs';
4-
import { concatMap, debounceTime, distinctUntilChanged, map, takeUntil, tap } from 'rxjs/operators';
4+
import { concatMap, debounceTime, takeUntil, tap } from 'rxjs/operators';
55
import { QueryParamNameDirective } from './query-param-name.directive';
66
import { QueryParamControl, QueryParamGroup } from './model';
77
import { isMissing } from './util';
@@ -12,7 +12,7 @@ import { isMissing } from './util';
1212
@Directive({
1313
selector: '[queryParamGroup]',
1414
})
15-
export class QueryParamGroupDirective implements OnDestroy {
15+
export class QueryParamGroupDirective implements OnInit, OnDestroy {
1616

1717
/** TODO Documentation */
1818
@Input('queryParamGroup')
@@ -21,7 +21,7 @@ export class QueryParamGroupDirective implements OnDestroy {
2121
/** TODO Documentation */
2222
private directives: QueryParamNameDirective[] = [];
2323

24-
/** TODO Documentation */
24+
/** TODO Documentation @internal */
2525
private queue$ = new Subject<Params>();
2626
private destroy$ = new Subject<void>();
2727

@@ -32,11 +32,41 @@ export class QueryParamGroupDirective implements OnDestroy {
3232
this.setupNavigationQueue();
3333
}
3434

35+
public ngOnInit() {
36+
Object.keys(this.queryParamGroup.controls).forEach(controlName => {
37+
const control: QueryParamControl<any> = this.queryParamGroup.get(controlName);
38+
control.registerOnChange((newModel: any) => this.enqueueNavigation(this.getParamsForModel(control, newModel)));
39+
});
40+
41+
this.route.queryParamMap.subscribe(queryParamMap => {
42+
Object.keys(this.queryParamGroup.controls).forEach(controlName => {
43+
const control: QueryParamControl<any> = this.queryParamGroup.get(controlName);
44+
const newModel = control.deserialize(queryParamMap.get(control.name));
45+
46+
// Get the directive, if it has been initialized yet.
47+
const directive = this.directives.find(dir => dir.name === controlName);
48+
if (!isMissing(directive)) {
49+
directive.valueAccessor.writeValue(newModel);
50+
}
51+
52+
control.value = newModel;
53+
control.updateValue({ emitEvent: true, onlySelf: true });
54+
});
55+
56+
// We used onlySelf on the controls so that we can emit only once on the entire group.
57+
this.queryParamGroup.updateValue({ emitEvent: true });
58+
});
59+
}
60+
3561
public ngOnDestroy() {
3662
this.destroy$.next();
3763
this.destroy$.complete();
3864
}
3965

66+
/**
67+
* TODO Documentation
68+
* @internal
69+
*/
4070
public addControl(directive: QueryParamNameDirective): void {
4171
const control: QueryParamControl<any> = this.queryParamGroup.get(directive.name);
4272
if (!control) {
@@ -46,41 +76,28 @@ export class QueryParamGroupDirective implements OnDestroy {
4676
throw new Error(`No value accessor found for the control. Please make sure to implement ControlValueAccessor on this component.`);
4777
}
4878

49-
if (isMissing(control.name)) {
50-
control.name = directive.name;
51-
}
79+
// Chances are that we read the initial route before a directive has been registered here.
80+
// The value in the control will be correct, but we need to sync it to the view once initially.
81+
directive.valueAccessor.writeValue(control.value);
5282

83+
// We proxy updates from the view to debounce them (if needed).
5384
const paramQueue$ = new Subject<Params>();
5485
paramQueue$.pipe(
5586
!isMissing(control.debounceTime) ? debounceTime(control.debounceTime) : tap(),
5687
takeUntil(this.destroy$),
5788
).subscribe(params => this.enqueueNavigation(params));
5889

59-
// View -> Model
60-
directive.valueAccessor.registerOnChange((newModel: any) => {
61-
paramQueue$.next({
62-
[control.name]: control.serialize(newModel)
63-
});
64-
});
65-
66-
// Model -> View
67-
this.route.queryParamMap.pipe(
68-
map(queryParamMap => queryParamMap.get(control.name)),
69-
distinctUntilChanged(),
70-
map(param => control.deserialize(param)),
71-
).subscribe(newModel => {
72-
// TODO We can probably replace this with the history state in Angular 7.2.0+
73-
if (control.compareWith(newModel, control.value)) {
74-
return;
75-
}
76-
77-
directive.valueAccessor.writeValue(newModel);
78-
control.value = newModel;
79-
});
90+
directive.valueAccessor.registerOnChange((newModel: any) =>
91+
paramQueue$.next(this.getParamsForModel(control, newModel))
92+
);
8093

8194
this.directives.push(directive);
8295
}
8396

97+
/**
98+
* TODO Documentation
99+
* @internal
100+
*/
84101
private setupNavigationQueue() {
85102
this.queue$.pipe(
86103
takeUntil(this.destroy$),
@@ -96,4 +113,10 @@ export class QueryParamGroupDirective implements OnDestroy {
96113
this.queue$.next(params);
97114
}
98115

116+
private getParamsForModel(control: QueryParamControl<any>, model: any): Params {
117+
return {
118+
[ control.name ]: control.serialize(model)
119+
};
120+
}
121+
99122
}

projects/ngqp/core/src/lib/query-param-name.directive.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Directive, Host, Inject, Input, OnChanges, OnInit, Optional, Self, SimpleChanges, SkipSelf } from '@angular/core';
1+
import { Directive, Host, Inject, Input, OnInit, Optional, Self, SkipSelf } from '@angular/core';
22
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
33
import { QueryParamGroupDirective } from './query-param-group.directive';
44
import { DefaultControlValueAccessorDirective } from './accessors/accessors';
@@ -9,7 +9,7 @@ import { DefaultControlValueAccessorDirective } from './accessors/accessors';
99
@Directive({
1010
selector: '[queryParamName]',
1111
})
12-
export class QueryParamNameDirective implements OnInit, OnChanges {
12+
export class QueryParamNameDirective implements OnInit {
1313

1414
/** TODO Documentation */
1515
@Input('queryParamName')
@@ -18,8 +18,6 @@ export class QueryParamNameDirective implements OnInit, OnChanges {
1818
/** TODO Documentation */
1919
public valueAccessor: ControlValueAccessor | null = null;
2020

21-
private added = false;
22-
2321
constructor(
2422
@Optional() @Host() @SkipSelf() private parent: QueryParamGroupDirective,
2523
@Optional() @Self() @Inject(NG_VALUE_ACCESSOR) valueAccessors: ControlValueAccessor[],
@@ -35,12 +33,8 @@ export class QueryParamNameDirective implements OnInit, OnChanges {
3533
if (!this.name) {
3634
throw new Error(`queryParamName has been added, but without specifying the name.`);
3735
}
38-
}
3936

40-
public ngOnChanges(changes: SimpleChanges) {
41-
if (!this.added) {
42-
this.setupControl();
43-
}
37+
this.setupControl();
4438
}
4539

4640
/**
@@ -73,7 +67,6 @@ export class QueryParamNameDirective implements OnInit, OnChanges {
7367

7468
private setupControl(): void {
7569
this.parent.addControl(this);
76-
this.added = true;
7770
}
7871

7972
}

0 commit comments

Comments
 (0)