Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 🎸 added option to handle prev/next button in the browser #31

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
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';
import { QueryParamDef } from './query-param-def';
import set from 'lodash-es/set';

export class BindQueryParamsManager<T = any> {
private defs: QueryParamDef<T>[];
private readonly defs: QueryParamDef<T>[];
private group!: FormGroup;
private destroy$ = new Subject();
private syncedDefs = {} as Record<keyof T, boolean>;
private readonly destroy$ = new Subject<void>();
private readonly syncedDefs = new Set<keyof T>();

connect(group: FormGroup) {
this.group = group;
Expand Down Expand Up @@ -73,6 +73,14 @@ export class BindQueryParamsManager<T = any> {
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() {
Expand Down Expand Up @@ -102,12 +110,15 @@ export class BindQueryParamsManager<T = any> {
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<T>[] = [];

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) {
Expand All @@ -126,9 +137,8 @@ export class BindQueryParamsManager<T = any> {
}

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() {
Expand Down Expand Up @@ -160,7 +170,7 @@ export class BindQueryParamsManager<T = any> {
this.router.navigate([], {
queryParams,
queryParamsHandling: 'merge',
replaceUrl: true,
replaceUrl: this.createOptions?.replaceUrl ?? true,
});
}

Expand All @@ -170,16 +180,11 @@ export class BindQueryParamsManager<T = any> {
updatePredicate = (_: QueryParamDef) => true
) {
const queryParams = this.search;
let value: Partial<T> = {};
const value: Partial<T> = Object.create(null);

for (const def of defs) {
if (updatePredicate(def)) {
const { queryKey } = def;
const queryParamValue = queryParams.get(queryKey);

if (!queryParamValue) continue;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed.
When using updateOn: 'submit', need to clear form controls to prev state

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why removed? can you please elaborate? I don't understand from your comment


set(value, def.path.split('.'), def.parse(queryParamValue));
set(value, def.path.split('.'), def.parse(queryParams.get(def.queryKey)));
}
}

Expand Down
82 changes: 65 additions & 17 deletions projects/ngneat/bind-query-params/src/lib/lib.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,11 +19,21 @@ function stubQueryParams(params: string) {
};
}

function stubRouter(events: Subject<NavigationStart>) {
return {
provide: Router,
useValue: {
navigate: jasmine.createSpy('navigate'),
events,
},
};
}

function assertRouterCall(spectator: Spectator<HomeComponent>, queryParams: Record<string, unknown>) {
expect(spectator.inject(Router).navigate).toHaveBeenCalledOnceWith([], {
queryParams,
queryParamsHandling: 'merge',
replaceUrl: true,
replaceUrl: false,
});

spectator.inject(Router).navigate.calls.reset();
Expand Down Expand Up @@ -64,22 +75,27 @@ class HomeComponent {
constructor(private factory: BindQueryParamsFactory) {}

bindQueryParams = this.factory
.create<Params>([
{ 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<Params>(
[
{ 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);
}

Expand All @@ -98,6 +114,7 @@ describe('BindQueryParams', () => {
provide: Router,
useValue: {
navigate: jasmine.createSpy('Router.navigate'),
events: new Subject(),
},
},
],
Expand Down Expand Up @@ -485,5 +502,36 @@ describe('BindQueryParams', () => {
);
});
});

describe('replaceUrl', () => {
it('should sync all control values after popstate changing', () => {
const events = new Subject<NavigationStart>();
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,
})
);
});
});
});
});
4 changes: 2 additions & 2 deletions projects/ngneat/bind-query-params/src/lib/query-param-def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export class QueryParamDef<QueryParams = any> {
return serializedValue === '[object Object]' ? JSON.stringify(controlValue) : serializedValue;
}

parse(queryParamValue: string) {
if (this.parser) {
parse(queryParamValue: string | null) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not pass null(undefined) values to custom parser function. Otherwise it causes breaking changes

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand your comment, why do you need this change?

if (this.parser && queryParamValue != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (this.parser && queryParamValue != null) {
if (this.parser && queryParamValue !== null) {

return this.parser(queryParamValue);
}

Expand Down
2 changes: 2 additions & 0 deletions projects/ngneat/bind-query-params/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ export interface ResolveParamsOption<T = any> {

export interface SyncDefsOptions {
emitEvent: boolean;
force?: boolean;
}

export type CreateOptions = Pick<QueryDefOptions, 'syncInitialControlValue' | 'syncInitialQueryParamValue'> & {
injector?: Injector;
replaceUrl?: boolean;
};