From 07e7e22823bb1fa18bc01e78456ba2c3302d344c Mon Sep 17 00:00:00 2001 From: Ivan Zubok Date: Mon, 19 Jun 2023 22:09:29 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20added=20option=20to?= =?UTF-8?q?=20handle=20prev/next=20button=20in=20the=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++- .../src/lib/bind-query-params-manager.ts | 34 ++++++++++++------- .../ngneat/bind-query-params/src/lib/types.ts | 2 ++ 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 058c04d..ca12574 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,10 @@ const def = { serializer: (value) => (value instanceof Date ? value.toISOString( Set the initial control value in the URL (defaults to `false`) ### `syncInitialQueryParamValue` -Sync the initial query paramater with the form group (defaults to `true`) +Sync the initial query parameter with the form group (defaults to `true`) + +### `replaceUrl` +When true, navigates while replacing the current state in history (defaults to `true`) #### Handle Async Data When working with async controls, such as a dropdown list whose options are coming from the server, we cannot update the control immediately. In those cases, you can set `syncInitialQueryParamValue` to `false`, which will force the control value to not be updated when the page loads. diff --git a/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts b/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts index 23be364..384b07d 100644 --- a/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts +++ b/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts @@ -1,6 +1,6 @@ import { FormGroup } from '@angular/forms'; import { merge, Subject } from 'rxjs'; -import { Router } from '@angular/router'; +import { NavigationStart, Router } from '@angular/router'; import { coerceArray, get, resolveParams } from './utils'; import { auditTime, map, takeUntil } from 'rxjs/operators'; import { BindQueryParamsOptions, CreateOptions, QueryDefOptions, ResolveParamsOption, SyncDefsOptions } from './types'; @@ -8,10 +8,10 @@ import { QueryParamDef } from './query-param-def'; import set from 'lodash-es/set'; export class BindQueryParamsManager { - private defs: QueryParamDef[]; + private readonly defs: QueryParamDef[]; private group!: FormGroup; - private destroy$ = new Subject(); - private syncedDefs = {} as Record; + private readonly destroy$ = new Subject(); + private readonly syncedDefs = new Set(); connect(group: FormGroup) { this.group = group; @@ -73,6 +73,14 @@ export class BindQueryParamsManager { this.updateQueryParams(resolveParams(buffer)); buffer = []; }); + + if (this.createOptions?.replaceUrl === false) { + this.router.events.pipe(takeUntil(this.destroy$)).subscribe((event) => { + if (event instanceof NavigationStart && event.navigationTrigger === 'popstate') { + this.syncAllDefs({ force: true, emitEvent: true }); + } + }); + } } destroy() { @@ -102,12 +110,15 @@ export class BindQueryParamsManager { this.syncDefs(allKeys, options); } - syncDefs(queryKeys: (keyof T & string) | (keyof T & string)[], options: SyncDefsOptions = { emitEvent: true }) { + syncDefs( + queryKeys: (keyof T & string) | (keyof T & string)[], + { force, ...options }: SyncDefsOptions = { emitEvent: true } + ) { const defs: QueryParamDef[] = []; coerceArray(queryKeys).forEach((key) => { - if (!this.syncedDefs[key]) { - this.syncedDefs[key] = true; + if (!this.syncedDefs.has(key) || force) { + this.syncedDefs.add(key); const def = this.getDef(key as keyof T); if (def) { @@ -126,9 +137,8 @@ export class BindQueryParamsManager { } someParamExists(): boolean { - return this.defs.some((def) => { - return this.search.has(def.queryKey); - }); + const search = this.search; + return this.defs.some((def) => search.has(def.queryKey)); } get search() { @@ -160,7 +170,7 @@ export class BindQueryParamsManager { this.router.navigate([], { queryParams, queryParamsHandling: 'merge', - replaceUrl: true, + replaceUrl: this.createOptions?.replaceUrl ?? true, }); } @@ -170,7 +180,7 @@ export class BindQueryParamsManager { updatePredicate = (_: QueryParamDef) => true ) { const queryParams = this.search; - let value: Partial = {}; + const value: Partial = Object.create(null); for (const def of defs) { if (updatePredicate(def)) { diff --git a/projects/ngneat/bind-query-params/src/lib/types.ts b/projects/ngneat/bind-query-params/src/lib/types.ts index b9935ea..bf511e9 100644 --- a/projects/ngneat/bind-query-params/src/lib/types.ts +++ b/projects/ngneat/bind-query-params/src/lib/types.ts @@ -25,8 +25,10 @@ export interface ResolveParamsOption { export interface SyncDefsOptions { emitEvent: boolean; + force?: boolean; } export type CreateOptions = Pick & { injector?: Injector; + replaceUrl?: boolean; }; From 95f97ccf1bb0dc27069d2c23c202de99fdb9ded5 Mon Sep 17 00:00:00 2001 From: Ivan Zubok Date: Tue, 20 Jun 2023 21:27:56 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20added=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/bind-query-params-manager.ts | 7 +- .../bind-query-params/src/lib/lib.spec.ts | 82 +++++++++++++++---- .../src/lib/query-param-def.ts | 4 +- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts b/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts index 384b07d..9ab4cbd 100644 --- a/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts +++ b/projects/ngneat/bind-query-params/src/lib/bind-query-params-manager.ts @@ -184,12 +184,7 @@ export class BindQueryParamsManager { for (const def of defs) { if (updatePredicate(def)) { - const { queryKey } = def; - const queryParamValue = queryParams.get(queryKey); - - if (!queryParamValue) continue; - - set(value, def.path.split('.'), def.parse(queryParamValue)); + set(value, def.path.split('.'), def.parse(queryParams.get(def.queryKey))); } } diff --git a/projects/ngneat/bind-query-params/src/lib/lib.spec.ts b/projects/ngneat/bind-query-params/src/lib/lib.spec.ts index bdc8066..5c6885c 100644 --- a/projects/ngneat/bind-query-params/src/lib/lib.spec.ts +++ b/projects/ngneat/bind-query-params/src/lib/lib.spec.ts @@ -2,8 +2,9 @@ import { BIND_QUERY_PARAMS_OPTIONS, BindQueryParamsFactory } from '@ngneat/bind- import { FormControl, FormGroup } from '@angular/forms'; import { createComponentFactory, Spectator } from '@ngneat/spectator'; import { Component } from '@angular/core'; -import { Router } from '@angular/router'; +import { NavigationStart, Router } from '@angular/router'; import { fakeAsync, tick } from '@angular/core/testing'; +import { Subject } from 'rxjs'; function stubQueryParams(params: string) { return { @@ -18,11 +19,21 @@ function stubQueryParams(params: string) { }; } +function stubRouter(events: Subject) { + return { + provide: Router, + useValue: { + navigate: jasmine.createSpy('navigate'), + events, + }, + }; +} + function assertRouterCall(spectator: Spectator, queryParams: Record) { expect(spectator.inject(Router).navigate).toHaveBeenCalledOnceWith([], { queryParams, queryParamsHandling: 'merge', - replaceUrl: true, + replaceUrl: false, }); spectator.inject(Router).navigate.calls.reset(); @@ -64,22 +75,27 @@ class HomeComponent { constructor(private factory: BindQueryParamsFactory) {} bindQueryParams = this.factory - .create([ - { queryKey: 'searchTerm' }, - { queryKey: 'withBrackets[gte]' }, - { queryKey: 'showErrors', type: 'boolean' }, - { queryKey: 'issues', type: 'array' }, - { queryKey: 'nested', path: 'a.b' }, - { queryKey: 'nestedarray', path: 'a.c', type: 'array' }, - { queryKey: 'parser', type: 'array', parser: (value) => value.split(',').map((v) => +v) }, + .create( + [ + { queryKey: 'searchTerm' }, + { queryKey: 'withBrackets[gte]' }, + { queryKey: 'showErrors', type: 'boolean' }, + { queryKey: 'issues', type: 'array' }, + { queryKey: 'nested', path: 'a.b' }, + { queryKey: 'nestedarray', path: 'a.c', type: 'array' }, + { queryKey: 'parser', type: 'array', parser: (value) => value.split(',').map((v) => +v) }, + { + queryKey: 'serializer', + parser: (value) => new Date(value), + serializer: (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : (value as any)), + }, + { queryKey: 'modelToUrl', type: 'array', syncInitialQueryParamValue: false }, + { queryKey: 'modelToUrl2', type: 'array', syncInitialQueryParamValue: false }, + ], { - queryKey: 'serializer', - parser: (value) => new Date(value), - serializer: (value) => (value instanceof Date ? value.toISOString().slice(0, 10) : (value as any)), - }, - { queryKey: 'modelToUrl', type: 'array', syncInitialQueryParamValue: false }, - { queryKey: 'modelToUrl2', type: 'array', syncInitialQueryParamValue: false }, - ]) + replaceUrl: false, + } + ) .connect(this.group); } @@ -98,6 +114,7 @@ describe('BindQueryParams', () => { provide: Router, useValue: { navigate: jasmine.createSpy('Router.navigate'), + events: new Subject(), }, }, ], @@ -485,5 +502,36 @@ describe('BindQueryParams', () => { ); }); }); + + describe('replaceUrl', () => { + it('should sync all control values after popstate changing', () => { + const events = new Subject(); + spectator = createComponent({ + providers: [stubQueryParams('modelToUrl=2,1&modelToUrl2=3,4&searchTerm=hello'), stubRouter(events)], + }); + + spectator.component.bindQueryParams.syncAllDefs(); + + expect(spectator.component.group.value).toEqual( + jasmine.objectContaining({ + modelToUrl: ['2', '1'], + modelToUrl2: ['3', '4'], + searchTerm: 'hello', + }) + ); + const newUrl = '?modelToUrl=1,3'; + + spectator.inject(BIND_QUERY_PARAMS_OPTIONS).windowRef.location.search = newUrl; + events.next(new NavigationStart(1, newUrl, 'popstate')); + + expect(spectator.component.group.value).toEqual( + jasmine.objectContaining({ + modelToUrl: ['1', '3'], + modelToUrl2: undefined, + searchTerm: null, + }) + ); + }); + }); }); }); diff --git a/projects/ngneat/bind-query-params/src/lib/query-param-def.ts b/projects/ngneat/bind-query-params/src/lib/query-param-def.ts index ebd3bf0..efd972b 100644 --- a/projects/ngneat/bind-query-params/src/lib/query-param-def.ts +++ b/projects/ngneat/bind-query-params/src/lib/query-param-def.ts @@ -50,8 +50,8 @@ export class QueryParamDef { return serializedValue === '[object Object]' ? JSON.stringify(controlValue) : serializedValue; } - parse(queryParamValue: string) { - if (this.parser) { + parse(queryParamValue: string | null) { + if (this.parser && queryParamValue != null) { return this.parser(queryParamValue); }