Angular Integration
Complete guide for using @mcabreradev/filter with Angular 17+.
Installation
bash
npm install @mcabreradev/filterImport
typescript
import {
FilterService,
FilterPipe,
DebouncedFilterService,
PaginatedFilterService
} from '@mcabreradev/filter/angular';Available Tools
FilterService- Injectable service with Signals supportFilterPipe- Declarative filtering in templatesDebouncedFilterService- Debounced filtering for searchPaginatedFilterService- Filtering with pagination
FilterService
Reactive filtering service using Angular Signals.
Basic Usage
typescript
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FilterService } from '@mcabreradev/filter/angular';
interface User {
id: number;
name: string;
active: boolean;
}
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule],
providers: [FilterService],
template: `
<div>
<button (click)="showActive()">Show Active</button>
<button (click)="filterService.reset()">Reset</button>
@if (filterService.isFiltering()) {
<span>Filtering...</span>
}
@for (user of filterService.filtered(); track user.id) {
<div class="user-card">
<h3>{{ user.name }}</h3>
</div>
}
</div>
`
})
export class UserListComponent implements OnInit {
filterService = inject(FilterService<User>);
users: User[] = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false },
];
ngOnInit() {
this.filterService.setData(this.users);
}
showActive() {
this.filterService.setExpression({ active: true });
}
}API Reference
typescript
class FilterService<T> {
// Signals (read-only)
filtered: Signal<T[]>;
isFiltering: Signal<boolean>;
// Methods
setData(data: T[]): void;
setExpression(expr: Expression<T> | null): void;
setOptions(opts: FilterOptions): void;
reset(): void;
}FilterPipe
Declarative filtering directly in templates.
typescript
import { Component } from '@angular/core';
import { FilterPipe } from '@mcabreradev/filter/angular';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [FilterPipe],
template: `
@for (user of users | filterPipe:{ active: true }; track user.id) {
<div>{{ user.name }}</div>
}
`
})
export class UserListComponent {
users = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false },
];
}API Reference
typescript
@Pipe({
name: 'filterPipe',
standalone: true,
pure: true
})
class FilterPipe implements PipeTransform {
transform<T>(
data: T[] | null | undefined,
expr: Expression<T>,
opts?: FilterOptions
): T[];
}DebouncedFilterService
Debounced filtering service for search inputs.
typescript
import { Component, inject, signal, effect } from '@angular/core';
import { DebouncedFilterService } from '@mcabreradev/filter/angular';
@Component({
selector: 'app-search',
standalone: true,
providers: [DebouncedFilterService],
template: `
<input
[value]="searchTerm()"
(input)="onSearchChange($event)"
placeholder="Search..."
/>
@if (filterService.isPending()) {
<span>Searching...</span>
}
@for (user of filterService.filtered(); track user.id) {
<div>{{ user.name }}</div>
}
`
})
export class SearchComponent {
filterService = inject(DebouncedFilterService<User>);
searchTerm = signal('');
users = signal<User[]>([...]);
constructor() {
this.filterService.setData(this.users());
effect(() => {
this.filterService.setExpressionDebounced(
{ name: { $contains: this.searchTerm() } },
300 // delay in ms
);
});
}
onSearchChange(event: Event): void {
const target = event.target as HTMLInputElement;
this.searchTerm.set(target.value);
}
}API Reference
typescript
class DebouncedFilterService<T> {
// Signals (read-only)
filtered: Signal<T[]>;
isFiltering: Signal<boolean>;
isPending: Signal<boolean>;
// Methods
setData(data: T[]): void;
setExpressionDebounced(expr: Expression<T> | null, delay?: number): void;
setOptions(opts: FilterOptions): void;
reset(): void;
}PaginatedFilterService
Filtering service with built-in pagination.
typescript
import { Component, inject, computed } from '@angular/core';
import { PaginatedFilterService } from '@mcabreradev/filter/angular';
@Component({
selector: 'app-paginated-list',
standalone: true,
providers: [PaginatedFilterService],
template: `
@for (item of filterService.paginatedResults(); track item.id) {
<div>{{ item.name }}</div>
}
<div>
<button
(click)="filterService.prevPage()"
[disabled]="filterService.currentPage() === 1"
>
Previous
</button>
<span>
Page {{ filterService.currentPage() }}
of {{ filterService.totalPages() }}
</span>
<button
(click)="filterService.nextPage()"
[disabled]="filterService.currentPage() === filterService.totalPages()"
>
Next
</button>
</div>
`
})
export class PaginatedListComponent {
filterService = inject(PaginatedFilterService<Product>);
products = signal<Product[]>([...]);
expression = signal({ inStock: true });
constructor() {
this.filterService.setData(this.products());
this.filterService.setExpression(this.expression());
this.filterService.setPageSize(10);
}
}API Reference
typescript
class PaginatedFilterService<T> {
// Signals (read-only)
filtered: Signal<T[]>;
isFiltering: Signal<boolean>;
paginatedResults: Signal<T[]>;
currentPage: Signal<number>;
pageSize: Signal<number>;
totalPages: Signal<number>;
// Methods
setData(data: T[]): void;
setExpression(expr: Expression<T> | null): void;
setOptions(opts: FilterOptions): void;
setPage(page: number): void;
setPageSize(size: number): void;
nextPage(): void;
prevPage(): void;
reset(): void;
}TypeScript Support
Full type safety with generics:
typescript
interface Product {
id: number;
name: string;
price: number;
}
@Component({
providers: [FilterService]
})
export class ProductsComponent {
filterService = inject(FilterService<Product>);
ngOnInit() {
this.filterService.setExpression({
price: { $gte: 100 } // ✅ Type-safe
});
}
}Real-World Examples
1. Search with Signals and Dynamic Sorting
Advanced search interface using Angular Signals with debounced input and reactive sorting.
typescript
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DebouncedFilterService } from '@mcabreradev/filter/angular';
interface Product {
id: number;
name: string;
price: number;
category: string;
rating: number;
inStock: boolean;
}
@Component({
selector: 'app-product-search',
standalone: true,
imports: [CommonModule, FormsModule],
providers: [DebouncedFilterService],
template: `
<div class="product-search">
<div class="filters">
<input
type="text"
placeholder="Search products..."
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
class="search-input"
/>
<select [ngModel]="category()" (ngModelChange)="category.set($event)">
<option value="">All Categories</option>
<option value="Electronics">Electronics</option>
<option value="Books">Books</option>
<option value="Clothing">Clothing</option>
</select>
<div class="price-filter">
<label>Max Price: $\{{ maxPrice() }}</label>
<input
type="range"
min="0"
max="10000"
[ngModel]="maxPrice()"
(ngModelChange)="maxPrice.set($event)"
/>
</div>
<div class="sort-controls">
<select [ngModel]="sortBy()" (ngModelChange)="sortBy.set($event)">
<option value="price">Price</option>
<option value="rating">Rating</option>
</select>
<button (click)="toggleSortDirection()">
{{ sortDir() === 'asc' ? '↑' : '↓' }}
</button>
</div>
</div>
@if (filterService.isFiltering()) {
<div class="loading">Searching...</div>
}
<div class="results">
<p>{{ filterService.filtered().length }} products found</p>
<div class="product-grid">
@for (product of filterService.filtered(); track product.id) {
<div class="product-card">
<h3>{{ product.name }}</h3>
<p class="category">{{ product.category }}</p>
<p class="price">\${{ product.price }}</p>
<p class="rating">⭐ {{ product.rating }}/5</p>
</div>
}
</div>
</div>
</div>
`,
styles: [`
.product-search {
padding: 1rem;
}
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 0.5rem;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.product-card {
border: 1px solid #ddd;
padding: 1rem;
border-radius: 8px;
}
`]
})
export class ProductSearchComponent implements OnInit {
filterService = inject(DebouncedFilterService<Product>);
searchTerm = signal('');
category = signal('');
maxPrice = signal(10000);
sortBy = signal<'price' | 'rating'>('price');
sortDir = signal<'asc' | 'desc'>('asc');
products: Product[] = [
{ id: 1, name: 'Laptop Pro', price: 1200, category: 'Electronics', rating: 4.5, inStock: true },
{ id: 2, name: 'Wireless Mouse', price: 25, category: 'Electronics', rating: 4.0, inStock: true },
// ... more products
];
// Reactive expression using computed
expression = computed(() => {
const expr: any = {
price: { $lte: this.maxPrice() },
inStock: true
};
if (this.searchTerm()) {
expr.name = { $contains: this.searchTerm() };
}
if (this.category()) {
expr.category = this.category();
}
return expr;
});
// Reactive options using computed
options = computed(() => ({
orderBy: { field: this.sortBy(), direction: this.sortDir() },
enableCache: true
}));
ngOnInit() {
this.filterService.setData(this.products);
// Watch expression changes
this.filterService.setExpression(this.expression());
this.filterService.setOptions(this.options());
this.filterService.setDebounce(300);
}
toggleSortDirection() {
this.sortDir.set(this.sortDir() === 'asc' ? 'desc' : 'asc');
this.filterService.setOptions(this.options());
}
}2. Data Table with Column Sorting
Sortable, filterable data table with column-based filtering and pagination.
typescript
import { Component, inject, OnInit, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { PaginatedFilterService } from '@mcabreradev/filter/angular';
interface User {
id: number;
name: string;
email: string;
role: string;
status: 'active' | 'inactive';
lastLogin: Date;
}
@Component({
selector: 'app-user-table',
standalone: true,
imports: [CommonModule, FormsModule],
providers: [PaginatedFilterService],
template: `
<div class="data-table">
<div class="table-filters">
<input
type="text"
placeholder="Filter by name..."
[ngModel]="nameFilter()"
(ngModelChange)="nameFilter.set($event)"
/>
<select [ngModel]="roleFilter()" (ngModelChange)="roleFilter.set($event)">
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="moderator">Moderator</option>
</select>
<select [ngModel]="statusFilter()" (ngModelChange)="statusFilter.set($event)">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
<table>
<thead>
<tr>
<th (click)="handleSort('name')">
Name {{ getSortIcon('name') }}
</th>
<th (click)="handleSort('email')">
Email {{ getSortIcon('email') }}
</th>
<th (click)="handleSort('role')">
Role {{ getSortIcon('role') }}
</th>
<th (click)="handleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th (click)="handleSort('lastLogin')">
Last Login {{ getSortIcon('lastLogin') }}
</th>
</tr>
</thead>
<tbody>
@for (user of filterService.paginatedResults(); track user.id) {
<tr>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.role }}</td>
<td>
<span [class]="'status-badge ' + user.status">
{{ user.status }}
</span>
</td>
<td>{{ user.lastLogin | date:'short' }}</td>
</tr>
}
</tbody>
</table>
<div class="pagination">
<button
(click)="filterService.prevPage()"
[disabled]="filterService.currentPage() === 1"
>
Previous
</button>
<span>
Page {{ filterService.currentPage() }} of {{ filterService.totalPages() }}
({{ filterService.filtered().length }} results)
</span>
<button
(click)="filterService.nextPage()"
[disabled]="filterService.currentPage() === filterService.totalPages()"
>
Next
</button>
</div>
</div>
`,
styles: [`
.data-table {
width: 100%;
}
.table-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
cursor: pointer;
user-select: none;
background: #f5f5f5;
padding: 0.75rem;
text-align: left;
}
td {
padding: 0.75rem;
border-bottom: 1px solid #ddd;
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.status-badge.active {
background: #d4edda;
color: #155724;
}
.status-badge.inactive {
background: #f8d7da;
color: #721c24;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1rem;
}
`]
})
export class UserTableComponent implements OnInit {
filterService = inject(PaginatedFilterService<User>);
nameFilter = signal('');
roleFilter = signal('');
statusFilter = signal('');
sortField = signal<keyof User>('name');
sortDirection = signal<'asc' | 'desc'>('asc');
users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin', status: 'active', lastLogin: new Date() },
{ id: 2, name: 'Bob', email: 'bob@example.com', role: 'user', status: 'active', lastLogin: new Date() },
// ... more users
];
expression = computed(() => {
const expr: any = {};
if (this.nameFilter()) {
expr.name = { $contains: this.nameFilter() };
}
if (this.roleFilter()) {
expr.role = this.roleFilter();
}
if (this.statusFilter()) {
expr.status = this.statusFilter();
}
return expr;
});
options = computed(() => ({
orderBy: { field: this.sortField(), direction: this.sortDirection() },
enableCache: true
}));
ngOnInit() {
this.filterService.setData(this.users);
this.filterService.setExpression(this.expression());
this.filterService.setOptions(this.options());
this.filterService.setPageSize(10);
}
handleSort(field: keyof User) {
if (this.sortField() === field) {
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
this.sortField.set(field);
this.sortDirection.set('asc');
}
this.filterService.setOptions(this.options());
this.filterService.setPage(1);
}
getSortIcon(field: keyof User): string {
if (this.sortField() !== field) return '↕️';
return this.sortDirection() === 'asc' ? '↑' : '↓';
}
}3. Infinite Scroll with Virtual Scrolling
Infinite scroll list using CDK Virtual Scrolling for memory-efficient rendering.
typescript
import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { FormsModule } from '@angular/forms';
import { filterLazy } from '@mcabreradev/filter';
interface Post {
id: number;
title: string;
content: string;
author: string;
tags: string[];
createdAt: Date;
}
@Component({
selector: 'app-infinite-list',
standalone: true,
imports: [CommonModule, ScrollingModule, FormsModule],
template: `
<div class="infinite-list">
<div class="filter-bar">
<select [ngModel]="selectedTag()" (ngModelChange)="changeTag($event)">
<option value="">All Tags</option>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="angular">Angular</option>
</select>
</div>
<cdk-virtual-scroll-viewport
itemSize="150"
class="viewport"
(scrolledIndexChange)="onScroll($event)"
>
<article
*cdkVirtualFor="let post of displayedPosts()"
class="post-card"
>
<h2>{{ post.title }}</h2>
<p class="author">By {{ post.author }}</p>
<p class="content">{{ post.content }}</p>
<div class="tags">
@for (tag of post.tags; track tag) {
<span class="tag">{{ tag }}</span>
}
</div>
<time>{{ post.createdAt | date:'short' }}</time>
</article>
@if (hasMore()) {
<div class="loading-trigger">
{{ isLoading() ? 'Loading more posts...' : 'Scroll for more' }}
</div>
} @else {
<div class="end-message">
No more posts ({{ displayedPosts().length }} total)
</div>
}
</cdk-virtual-scroll-viewport>
</div>
`,
styles: [`
.infinite-list {
max-width: 800px;
margin: 0 auto;
height: 100vh;
}
.viewport {
height: calc(100vh - 60px);
}
.post-card {
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
margin: 0.5rem;
}
.tags {
display: flex;
gap: 0.5rem;
margin: 0.5rem 0;
}
.tag {
padding: 0.25rem 0.5rem;
background: #e0e0e0;
border-radius: 4px;
font-size: 0.875rem;
}
.loading-trigger,
.end-message {
text-align: center;
padding: 2rem;
color: #666;
}
`]
})
export class InfiniteListComponent implements OnInit {
displayedPosts = signal<Post[]>([]);
hasMore = signal(true);
isLoading = signal(false);
selectedTag = signal('');
posts: Post[] = [];
iterator: Generator<Post, void, undefined> | null = null;
ngOnInit() {
// Initialize with sample data
this.posts = Array.from({ length: 1000 }, (_, i) => ({
id: i,
title: `Post ${i + 1}`,
content: `Content for post ${i + 1}...`,
author: `Author ${i % 10}`,
tags: ['javascript', 'typescript', 'angular'].slice(0, Math.floor(Math.random() * 3) + 1),
createdAt: new Date()
}));
this.initializeIterator();
}
initializeIterator() {
const expression = this.selectedTag()
? { tags: { $contains: this.selectedTag() } }
: {};
this.iterator = filterLazy(this.posts, expression);
this.displayedPosts.set([]);
this.hasMore.set(true);
this.loadMore();
}
loadMore() {
if (!this.iterator || this.isLoading()) return;
this.isLoading.set(true);
const newPosts: Post[] = [];
for (let i = 0; i < 20; i++) {
const result = this.iterator.next();
if (result.done) {
this.hasMore.set(false);
break;
}
newPosts.push(result.value);
}
this.displayedPosts.set([...this.displayedPosts(), ...newPosts]);
this.isLoading.set(false);
}
onScroll(index: number) {
const threshold = this.displayedPosts().length - 5;
if (index >= threshold && this.hasMore() && !this.isLoading()) {
this.loadMore();
}
}
changeTag(tag: string) {
this.selectedTag.set(tag);
this.initializeIterator();
}
}4. NgRx Store Integration
Integration with NgRx for global state management with filtering.
typescript
// filter.state.ts
import { createFeature, createReducer, createSelector, on } from '@ngrx/store';
import { createAction, props } from '@ngrx/store';
import type { Expression, FilterOptions } from '@mcabreradev/filter';
interface Product {
id: number;
name: string;
category: string;
price: number;
}
export interface FilterState {
expression: Expression<Product>;
sortBy: string;
sortDirection: 'asc' | 'desc';
enableCache: boolean;
}
const initialState: FilterState = {
expression: {},
sortBy: 'name',
sortDirection: 'asc',
enableCache: true
};
// Actions
export const filterActions = {
setExpression: createAction(
'[Filter] Set Expression',
props<{ expression: Expression<Product> }>()
),
setSortBy: createAction(
'[Filter] Set Sort By',
props<{ field: string }>()
),
toggleSortDirection: createAction('[Filter] Toggle Sort Direction'),
resetFilters: createAction('[Filter] Reset Filters')
};
// Reducer
export const filterFeature = createFeature({
name: 'filter',
reducer: createReducer(
initialState,
on(filterActions.setExpression, (state, { expression }) => ({
...state,
expression
})),
on(filterActions.setSortBy, (state, { field }) => ({
...state,
sortBy: field
})),
on(filterActions.toggleSortDirection, (state) => ({
...state,
sortDirection: state.sortDirection === 'asc' ? 'desc' : 'asc'
})),
on(filterActions.resetFilters, () => initialState)
),
extraSelectors: ({ selectExpression, selectSortBy, selectSortDirection, selectEnableCache }) => ({
selectFilterOptions: createSelector(
selectSortBy,
selectSortDirection,
selectEnableCache,
(sortBy, sortDirection, enableCache): FilterOptions => ({
orderBy: { field: sortBy, direction: sortDirection },
enableCache
})
)
})
});typescript
// Component using NgRx
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { filter } from '@mcabreradev/filter';
import { filterActions, filterFeature } from './filter.state';
interface Product {
id: number;
name: string;
category: string;
price: number;
}
@Component({
selector: 'app-product-catalog',
standalone: true,
imports: [CommonModule],
template: `
<div class="product-catalog">
<div class="filters">
<button (click)="filterByCategory('Electronics')">
Electronics
</button>
<button (click)="filterByCategory('Books')">
Books
</button>
<button (click)="filterByPriceRange(0, 100)">
Under $100
</button>
<button (click)="resetFilters()">
Clear Filters
</button>
</div>
<div class="sort-controls">
<select
[value]="sortBy()"
(change)="setSortBy($any($event.target).value)"
>
<option value="name">Name</option>
<option value="price">Price</option>
<option value="category">Category</option>
</select>
<button (click)="toggleSort()">
{{ sortDirection() === 'asc' ? '↑ Ascending' : '↓ Descending' }}
</button>
</div>
<div class="product-grid">
<p>{{ filteredProducts().length }} products</p>
@for (product of filteredProducts(); track product.id) {
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>{{ product.category }}</p>
<p class="price">\${{ product.price }}</p>
</div>
}
</div>
</div>
`,
styles: [`
.product-catalog {
padding: 1rem;
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.sort-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.product-card {
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
`]
})
export class ProductCatalogComponent implements OnInit {
private store = inject(Store);
products = signal<Product[]>([
{ id: 1, name: 'Laptop', category: 'Electronics', price: 1200 },
{ id: 2, name: 'Book', category: 'Books', price: 25 },
// ... more products
]);
expression = this.store.selectSignal(filterFeature.selectExpression);
sortBy = this.store.selectSignal(filterFeature.selectSortBy);
sortDirection = this.store.selectSignal(filterFeature.selectSortDirection);
filterOptions = this.store.selectSignal(filterFeature.selectFilterOptions);
filteredProducts = computed(() =>
filter(this.products(), this.expression(), this.filterOptions())
);
ngOnInit() {}
filterByCategory(category: string) {
this.store.dispatch(filterActions.setExpression({
expression: { category }
}));
}
filterByPriceRange(min: number, max: number) {
this.store.dispatch(filterActions.setExpression({
expression: { price: { $gte: min, $lte: max } }
}));
}
setSortBy(field: string) {
this.store.dispatch(filterActions.setSortBy({ field }));
}
toggleSort() {
this.store.dispatch(filterActions.toggleSortDirection());
}
resetFilters() {
this.store.dispatch(filterActions.resetFilters());
}
}5. Angular Universal SSR
Server-side rendering with Angular Universal and client-side filtering.
typescript
// app/products/products.component.ts
import { Component, computed, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { filter } from '@mcabreradev/filter';
interface Product {
id: number;
name: string;
category: string;
price: number;
inStock: boolean;
}
@Component({
selector: 'app-products-page',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="products-page">
<h1>Product Catalog</h1>
<div class="filters-panel">
<input
type="search"
placeholder="Search products..."
[ngModel]="searchTerm()"
(ngModelChange)="searchTerm.set($event)"
class="search-input"
/>
<select [ngModel]="category()" (ngModelChange)="category.set($event)">
<option value="">All Categories</option>
<option value="Electronics">Electronics</option>
<option value="Books">Books</option>
<option value="Clothing">Clothing</option>
</select>
<div class="price-range">
<label>
Price Range: ${{ priceRange()[0] }} - ${{ priceRange()[1] }}
</label>
<input
type="range"
min="0"
max="10000"
[ngModel]="priceRange()[1]"
(ngModelChange)="updatePriceRange($event)"
/>
</div>
<label class="checkbox">
<input
type="checkbox"
[ngModel]="showInStockOnly()"
(ngModelChange)="showInStockOnly.set($event)"
/>
In Stock Only
</label>
<div class="filter-stats">
{{ filteredProducts().length }} products found
</div>
</div>
<div class="products-grid">
@for (product of filteredProducts(); track product.id) {
<div class="product-card">
<h3>{{ product.name }}</h3>
<p class="category">{{ product.category }}</p>
<p class="price">\${{ product.price.toFixed(2) }}</p>
<p class="stock">
{{ product.inStock ? '✅ In Stock' : '❌ Out of Stock' }}
</p>
</div>
}
@if (filteredProducts().length === 0) {
<div class="no-results">
<p>No products match your filters</p>
<button (click)="clearFilters()">
Clear All Filters
</button>
</div>
}
</div>
</div>
`,
styles: [`
.products-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.filters-panel {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 2rem;
}
.search-input {
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
.product-card {
padding: 1.5rem;
border: 1px solid #ddd;
border-radius: 8px;
transition: box-shadow 0.2s;
}
.product-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.no-results {
grid-column: 1 / -1;
text-align: center;
padding: 3rem;
}
.checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
}
`]
})
export class ProductsPageComponent implements OnInit {
private http = inject(HttpClient);
// Server-side data fetching
products = toSignal(
this.http.get<Product[]>('/api/products'),
{ initialValue: [] }
);
searchTerm = signal('');
category = signal('');
priceRange = signal<[number, number]>([0, 10000]);
showInStockOnly = signal(false);
// Client-side filtering
expression = computed(() => {
const expr: any = {
price: { $gte: this.priceRange()[0], $lte: this.priceRange()[1] }
};
if (this.searchTerm()) {
expr.$or = [
{ name: { $contains: this.searchTerm() } },
{ category: { $contains: this.searchTerm() } }
];
}
if (this.category()) {
expr.category = this.category();
}
if (this.showInStockOnly()) {
expr.inStock = true;
}
return expr;
});
filteredProducts = computed(() =>
filter(this.products(), this.expression(), {
orderBy: 'name',
enableCache: true
})
);
ngOnInit() {}
updatePriceRange(max: number) {
this.priceRange.set([this.priceRange()[0], max]);
}
clearFilters() {
this.searchTerm.set('');
this.category.set('');
this.priceRange.set([0, 10000]);
this.showInStockOnly.set(false);
}
}typescript
// server.ts - Angular Universal setup
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// API endpoint for products
server.get('/api/products', (req, res) => {
const products = [
{ id: 1, name: 'Laptop Pro', category: 'Electronics', price: 1200, inStock: true },
{ id: 2, name: 'Mouse Wireless', category: 'Electronics', price: 25, inStock: true },
// ... more products
];
res.json(products);
});
// Serve static files
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y'
}));
// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});
return server;
}
function run(): void {
const port = process.env['PORT'] || 4000;
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
run();SSR Support
All tools are SSR-compatible and work with Angular Universal.
typescript
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { FilterService } from '@mcabreradev/filter/angular';
@Component({
selector: 'app-ssr-component',
standalone: true,
providers: [FilterService],
template: `...`
})
export class SSRComponent {
filterService = inject(FilterService<Product>);
platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Client-side only code
this.filterService.setOptions({ enableCache: true });
}
}
}Best Practices
1. Use Signals for Reactivity
typescript
const searchTerm = signal('');
const expression = computed(() => ({
name: { $contains: searchTerm() }
}));2. Provide Services at Component Level
typescript
@Component({
providers: [FilterService], // Component-scoped
// ...
})3. Use FilterPipe for Simple Cases
typescript
// Simple filtering
@for (item of items | filterPipe:{ active: true }; track item.id) {
<div>{{ item.name }}</div>
}4. Use Services for Complex Logic
typescript
// Complex filtering with multiple updates
filterService.setExpression({ category: 'Electronics' });
filterService.setOptions({ enableCache: true });