Skip to content

Commit

Permalink
feat: add linkedQueryParam implementation, tests and docs
Browse files Browse the repository at this point in the history
The `linkedQueryParam` utility function creates a signal that is linked to a query parameter in the URL. This allows you to easily keep your Angular application's state in sync with the URL, making it easier to share and bookmark specific views.
  • Loading branch information
eneajaho committed Dec 1, 2024
1 parent bd0b134 commit 097819a
Show file tree
Hide file tree
Showing 19 changed files with 1,939 additions and 0 deletions.
24 changes: 24 additions & 0 deletions apps/test-app/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,34 @@ import { RouterLink, RouterOutlet } from '@angular/router';
<li>
<a routerLink="/form-events">Form Events</a>
</li>
<li>
<a routerLink="/linked-query-param">Linked Query Param</a>
</li>
</ul>
<hr />
<router-outlet />
`,

styles: `
ul {
display: flex;
justify-content: flex-start;
gap: 5px;
list-style: none;
}
a {
text-decoration: none;
border: 1px solid black;
padding: 5px;
color: black;
}
a:hover {
background: black;
color: white;
}
`,
})
export class AppComponent {}
5 changes: 5 additions & 0 deletions apps/test-app/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export const appConfig: ApplicationConfig = {
loadComponent: () =>
import('./control-value-accessor/control-value-accessor'),
},
{
path: 'linked-query-param',
loadChildren: () =>
import('./linked-query-param/routes').then((x) => x.routes),
},
]),
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { JsonPipe } from '@angular/common';
import { Component, effect } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { linkedQueryParam } from 'ngxtension/linked-query-param';

@Component({
standalone: true,
template: `
<div>
@for (id of IDs; track $index) {
<label>
<input
type="checkbox"
[ngModel]="selectedCategoriesIds().includes(id)"
(ngModelChange)="$event ? selectId(id) : deselectId(id)"
/>
{{ id }}
</label>
}
</div>
`,
imports: [FormsModule, JsonPipe],
styles: `
div {
padding: 20px;
}
`,
})
export default class LinkedQueryParamArrayCmp {
IDs = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'];

selectedCategoriesIds = linkedQueryParam<string[]>('selectedCategoriesIds', {
parse: (x) => (x ? x.split(',').map((x) => x.trim()) : []),
serialize: (x) => x.join(','),
});

constructor() {
effect(() => {
console.log(
'selectedCategoriesIds Value: ',
this.selectedCategoriesIds(),
);
});
}

selectId(id: string) {
this.selectedCategoriesIds.update((x) => [...x, id]);
}

deselectId(id: string) {
this.selectedCategoriesIds.update((x) => x.filter((x) => x !== id));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { JsonPipe } from '@angular/common';
import { Component, effect, inject, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { linkedQueryParam } from 'ngxtension/linked-query-param';

@Component({
standalone: true,
template: `
<div>
<pre>SearchQuery: {{ searchQuery() | json }}</pre>
<div>
<label>
Different inputs same signal:
<input [(ngModel)]="searchQuery" name="a" placeholder="searchQuery" />
<input [(ngModel)]="searchQuery" name="b" placeholder="searchQuery" />
</label>
</div>
<!-- <div>-->
<!-- <label>-->
<!-- Different signals same query param:-->
<!-- <input-->
<!-- [(ngModel)]="differentSignalWithSearchQuery"-->
<!-- name="c"-->
<!-- placeholder="differentSignalWithSearchQuery"-->
<!-- />-->
<!-- </label>-->
<!-- </div>-->
<button type="button" (click)="searchQuery.set('cool')">cool</button>
<button type="button" (click)="searchQuery.set('great')">great</button>
<button type="button" (click)="searchQuery.set(null)">Reset</button>
</div>
`,
imports: [FormsModule, JsonPipe],
styles: `
div {
padding: 20px;
}
`,
})
export default class LinkedQueryParamStringCmp {
private titleService = inject(Title);

searchQuery = linkedQueryParam('searchQuery', {
// NOTE in docs that serialize should come after parse in order for types to work correctly
// parse: (x) => x ? parseInt(x, 10) : null,
// serialize: (x) => x,
});

// differentSignalWithSearchQuery = linkedQueryParam('searchQuery', {
// defaultValue: 'default',
// });

constructor() {
effect(() => {
console.log('searchQuery Type: ', typeof this.searchQuery());
console.log('searchQuery Value: ', this.searchQuery());

untracked(() => {
if (this.searchQuery()) {
this.titleService.setTitle(this.searchQuery()!);
} else {
this.titleService.setTitle('No search query!');
}
});
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { JsonPipe } from '@angular/common';
import { Component, effect } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { linkedQueryParam } from 'ngxtension/linked-query-param';

@Component({
standalone: true,
template: `
<div>
<pre>Show Deleted: {{ showDeleted() | json }}</pre>
<div>
<label>
<input type="checkbox" [(ngModel)]="showDeleted" name="showDeleted" />
Show Deleted signal
</label>
</div>
<div>
<label>
<input
type="checkbox"
[(ngModel)]="showDeletedOtherSignal"
name="showDeletedOtherSignal"
/>
Show Deleted other signal (same query param)
</label>
</div>
<button type="button" (click)="showDeleted.set(true)">True</button>
<button type="button" (click)="showDeleted.set(false)">False</button>
<button type="button" (click)="showDeleted.set(null)">Reset</button>
</div>
`,
imports: [FormsModule, JsonPipe],
styles: `
div {
padding: 20px;
}
`,
})
export default class LinkedQueryParamBooleansCmp {
showDeleted = linkedQueryParam('showDeleted', {
parse: (x) => x === 'true',
serialize: (x) => (x ? 'true' : 'false'),
});

showDeletedOtherSignal = linkedQueryParam('showDeleted', {
parse: (x) => x === 'true',
serialize: (x) => (x ? 'true' : 'false'),
});

constructor() {
effect(() => {
console.log('showDeleted Type: ', typeof this.showDeleted());
console.log('showDeleted Value: ', this.showDeleted());
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { JsonPipe } from '@angular/common';
import { Component, effect, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { linkedQueryParam } from 'ngxtension/linked-query-param';

interface MyFilters {
a: string;
b: string;
c: {
d: string;
e: string;
};
}

@Component({
standalone: true,
template: `
<div>
<label>
SearchQuery:
<input [(ngModel)]="filterState.searchQuery" name="searchQuery" />
<button type="button" (click)="filterState.searchQuery.set(null)">
reset
</button>
</label>
<br />
<label>
Size:
<select [(ngModel)]="filterState.pageSize" name="pageSize">
<option value="10">10</option>
<option value="20">20</option>
<option value="30">30</option>
<option [ngValue]="null">null</option>
</select>
</label>
<br />
<label>
Show deleted:
<input
[(ngModel)]="filterState.showDeleted"
name="showDeleted"
type="checkbox"
/>
</label>
<br />
<label>
Page:
<input [(ngModel)]="filterState.pageNumber" name="pageNumber" />
<button type="button" (click)="filterState.pageNumber.set(1)">1</button>
<button type="button" (click)="filterState.pageNumber.set(2)">2</button>
<button type="button" (click)="filterState.pageNumber.set(3)">3</button>
<button type="button" (click)="filterState.pageNumber.set(null)">
null
</button>
</label>
<br />
<label>
SortBy:
<select [(ngModel)]="filterState.sortBy" name="sortBy">
<option value="name">name</option>
<option value="age">age</option>
</select>
</label>
<br />
<label>
Direction:
<select [(ngModel)]="filterState.direction" name="direction">
<option value="asc">asc</option>
<option value="desc">desc</option>
</select>
</label>
<br />
<button type="button" (click)="filterState.pageSize.set(10)">
pageSize 10
</button>
<button type="button" (click)="filterState.pageSize.set(20)">
pageSize 20
</button>
<button type="button" (click)="filterState.pageSize.set(null)">
pageSize null
</button>
<button type="button" (click)="resetAll()">reset all</button>
<hr />
<br />
<br />
<hr />
<br />
</div>
`,
imports: [FormsModule, JsonPipe],
styles: `
div {
padding: 20px;
}
`,
})
export default class LinkedQueryParamInsideObjectCmp {
private router = inject(Router);

filterState = {
searchQuery: linkedQueryParam('searchQuery'),
showDeleted: linkedQueryParam('showDeleted', {
parse: (x) => x === 'true',
}),
pageNumber: linkedQueryParam('page', { parse: (x) => (x ? +x : null) }),
pageSize: linkedQueryParam('pageSize', { parse: (x) => (x ? +x : null) }),
sortBy: linkedQueryParam('sortBy', { defaultValue: 'name' }),
direction: linkedQueryParam<'asc' | 'desc'>('direction', {
defaultValue: 'asc',
}),
};

constructor() {
Object.keys(this.filterState).forEach((key) => {
effect(() => {
// @ts-ignore
console.log(key, this.filterState[key]());
});
});
}

resetAll() {
this.router.navigate([], {
queryParams: {
searchQuery: null,
showDeleted: null,
page: null,
pageSize: null,
sortBy: null,
direction: null,
},
});
}
}
Loading

0 comments on commit 097819a

Please sign in to comment.