Skip to content

Commit

Permalink
feat: add provideLinkedQueryParamConfig function and updated the beha…
Browse files Browse the repository at this point in the history
…vior for defaultValue

feat(linked-query-param): only schedule navigation on changes
  • Loading branch information
eneajaho committed Jan 25, 2025
1 parent 8032ec7 commit 84313ad
Show file tree
Hide file tree
Showing 2 changed files with 113 additions and 11 deletions.
49 changes: 47 additions & 2 deletions libs/ngxtension/linked-query-param/src/linked-query-param.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing';

import { ActivatedRoute, provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { injectRouteFragment } from 'libs/ngxtension/inject-route-fragment/src/inject-route-fragment';
import {
linkedQueryParam,
paramToBoolean,
paramToNumber,
provideLinkedQueryParamConfig,
} from './linked-query-param';

interface MyParams {
Expand Down Expand Up @@ -118,6 +120,10 @@ describe(linkedQueryParam.name, () => {
component: WithDefaultAndParseComponent,
},
{ path: 'with-injector-in-oninit', component: WithInjectorInOnInit },
{
path: 'with-preserve-fragment',
component: WithPreserveFragmentComponent,
},
]),
],
});
Expand Down Expand Up @@ -276,12 +282,12 @@ describe(linkedQueryParam.name, () => {

instance.defaultBehaviorWithDefault.set(null);

expect(instance.defaultBehaviorWithDefault()).toBe(null);
expect(instance.defaultBehaviorWithDefault()).toBe('default');

tick();
expect(
instance.route.snapshot.queryParams['defaultBehaviorWithDefault'],
).toBe(undefined);
).toBe('default');
}));

it('should handle boolean values', fakeAsync(async () => {
Expand Down Expand Up @@ -479,6 +485,34 @@ describe(linkedQueryParam.name, () => {
undefined,
);
}));

it('should work with preserveFragment', fakeAsync(async () => {
const harness = await RouterTestingHarness.create();
const instance = await harness.navigateByUrl(
'/with-preserve-fragment#foo',
WithPreserveFragmentComponent,
);

expect(instance.fragment()).toBe('foo');
expect(instance.route.snapshot.fragment).toBe('foo');

instance.searchQuery.set('bar');
tick();
expect(instance.searchQuery()).toBe('bar');
expect(instance.route.snapshot.queryParams['searchQuery']).toBe('bar');
expect(instance.route.snapshot.fragment).toBe('foo');
expect(instance.fragment()).toBe('foo');

await harness.navigateByUrl('/with-preserve-fragment#foo3');

expect(instance.fragment()).toBe('foo3');

instance.searchQuery.set('baz');
tick();
expect(instance.searchQuery()).toBe('baz');
expect(instance.route.snapshot.queryParams['searchQuery']).toBe('baz');
expect(instance.route.snapshot.fragment).toBe('foo3');
}));
});

@Component({ standalone: true, template: `` })
Expand All @@ -493,6 +527,17 @@ export class WithInjectorInOnInit implements OnInit {
}
}

@Component({
standalone: true,
template: ``,
providers: [provideLinkedQueryParamConfig({ preserveFragment: true })],
})
export class WithPreserveFragmentComponent {
route = inject(ActivatedRoute);
readonly fragment = injectRouteFragment();
readonly searchQuery = linkedQueryParam('searchQuery');
}

@Component({ standalone: true, template: `` })
export class WithDefaultAndParseComponent {
route = inject(ActivatedRoute);
Expand Down
75 changes: 66 additions & 9 deletions libs/ngxtension/linked-query-param/src/linked-query-param.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {
effect,
inject,
Injectable,
InjectionToken,
Injector,
Provider,
runInInjectionContext,
signal,
untracked,
Expand All @@ -17,6 +19,8 @@ import {
} from '@angular/router';
import { assertInjector } from 'ngxtension/assert-injector';
import { createNotifier } from 'ngxtension/create-notifier';
import { explicitEffect } from 'ngxtension/explicit-effect';

import { distinctUntilKeyChanged, map } from 'rxjs';

/**
Expand All @@ -38,6 +42,47 @@ type NavigateMethodFields = Pick<
| 'preserveFragment'
>;

const defaultConfig: Partial<NavigateMethodFields> = {
queryParamsHandling: 'merge',
};

const _LINKED_QUERY_PARAM_CONFIG_TOKEN = new InjectionToken<
Partial<NavigateMethodFields>
>('LinkedQueryParamConfig', {
providedIn: 'root',
factory: () => defaultConfig,
});

/*
* This function allows users to override the default behavior of the `linkedQueryParam` navigation extras per component.
*
* @example
* ```ts
* @Component({
* providers: [
* provideLinkedQueryParamConfig({ preserveFragment: true })
* ]
* })
* export class MyComponent {
* // No matter which query param changes, the `preserveFragment` option
* // will be set to `true` for all the `linkedQueryParam` functions in this component.
* readonly searchQuery = linkedQueryParam('searchQuery');
* readonly page = linkedQueryParam('page');
* }
* ```
*
* As always, you can override this behavior on a per-function basis by passing the navigation extras to the `linkedQueryParam` function.
*
*/
export function provideLinkedQueryParamConfig(
config: Partial<NavigateMethodFields>,
): Provider {
return {
provide: _LINKED_QUERY_PARAM_CONFIG_TOKEN,
useValue: config,
};
}

/**
* Service to coalesce multiple navigation calls into a single navigation event.
*/
Expand All @@ -63,10 +108,12 @@ export class LinkedQueryParamGlobalHandler {
constructor() {
effect(() => {
// listen to the scheduler notifier to schedule the navigation event
this._schedulerNotifier.listen();

// we need to untrack the navigation call in order to not register any other signal as a dependency
untracked(() => void this.navigate());
// we wrap the listen in a condition (listen() default value is 0) in order to not schedule
// the first navigation event by default, because only changes should trigger it
if (this._schedulerNotifier.listen()) {
// we need to untrack the navigation call in order to not register any other signal as a dependency
untracked(() => void this.navigate());
}
});
}

Expand Down Expand Up @@ -121,7 +168,6 @@ export class LinkedQueryParamGlobalHandler {
return this._router
.navigate([], {
queryParams: this._currentKeys,
queryParamsHandling: 'merge', // can be overridden by the `queryParamsHandling` option
...this._navigationExtras, // override the navigation extras
})
.then((value) => {
Expand Down Expand Up @@ -288,6 +334,7 @@ export function linkedQueryParam<T>(
return runInInjectionContext(injector, () => {
const route = inject(ActivatedRoute);
const globalHandler = inject(LinkedQueryParamGlobalHandler);
const config = inject(_LINKED_QUERY_PARAM_CONFIG_TOKEN);

/**
* Parses a parameter value based on provided configuration.
Expand Down Expand Up @@ -325,13 +372,20 @@ export function linkedQueryParam<T>(

const originalSet = source.set;

effect(() => {
const x = queryParamValue();
explicitEffect([queryParamValue], ([value]) => {
// update the source signal whenever the query param changes
untracked(() => originalSet(x as T));
originalSet(value as T);
});

const set = (value: T) => {
// first we check if the value is undefined or null so we can set the default value instead
if (
(value === undefined || value === null) &&
options?.defaultValue !== undefined
) {
value = options.defaultValue;
}

// we first set the initial value so it synchronous (same as a normal signal)
originalSet(value);

Expand All @@ -347,7 +401,10 @@ export function linkedQueryParam<T>(
}

globalHandler.setParamKeyValue(key, valueToBeSet);
globalHandler.setCurrentNavigationExtras(options ?? {});
globalHandler.setCurrentNavigationExtras({
...config,
...(options ?? {}),
});

// schedule the navigation event (multiple synchronous navigations will be coalesced)
// this will also reset the current keys and navigation extras after the navigation
Expand Down

0 comments on commit 84313ad

Please sign in to comment.