Skip to content

Commit

Permalink
feat(signals): new signals package with ngrx signals common store fea…
Browse files Browse the repository at this point in the history
…tures

This is a new package called @ngrx-traits/signals
Implements the following traits

withCalls Similar to react query it allows you to define some calls and it adds a loading track and a prop to store it
withEntitiesRemoteFilter adds states and methods for remote filtering logic
withEntitiesLocalFilter adds states and methods for local filtering logic
withEntitiesLocalPagination add states, compute and methods for local pagination
withEntitiesRemotePagination add states, compute and methods for remote pagination
withEntitiesLocalSort add states and methods needed for local sorting
withEntitiesRemoteSort add states and methods needed for remote sorting
withEntitiesSingleSelection add states and methods for single selection of entities
withEntitiesMultiSelection add states and methods for multi selection of entities
withCallStatus add states and methods for handling the loading of state related to a call
withLogger great for debugging logs any change to the state
  • Loading branch information
Gabriel Guerrero authored and gabrielguerrero committed Apr 29, 2024
1 parent efd1445 commit 92b52eb
Show file tree
Hide file tree
Showing 49 changed files with 8,923 additions and 5,319 deletions.
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@typescript-eslint/ban-type": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/no-non-null-assertion": "off",
Expand Down
10 changes: 8 additions & 2 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
{
"singleQuote": true
}
"singleQuote": true,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": ["^zone.js(.*)$", "<THIRD_PARTY_MODULES>", "^@turntown/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderCaseInsensitive": true,
"importOrderParserPlugins": ["typescript", "classProperties", "decorators-legacy"]
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import {
Component,
effect,
ElementRef,
inject,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
viewChild,
} from '@angular/core';
import { UntypedFormControl, ReactiveFormsModule } from '@angular/forms';
import { MatSelect as MatSelect } from '@angular/material/select';
import { Observable, Subject } from 'rxjs';
import { takeUntil, delay } from 'rxjs/operators';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { input } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { input } from '@angular/core';
import { MatSelect } from '@angular/material/select';
import { Observable, Subject } from 'rxjs';
import { delay, takeUntil } from 'rxjs/operators';

@Component({
selector: 'search-options',
Expand Down Expand Up @@ -67,15 +70,24 @@ import { input } from '@angular/core';
MatIconModule,
],
})
export class SearchOptionsComponent implements OnInit, OnDestroy {
export class SearchOptionsComponent {
placeholder = input('Search...');

control = new UntypedFormControl();
destroy = new Subject<void>();

@Output() valueChanges = this.control.valueChanges as Observable<string>;

@ViewChild('input', { static: true }) input: ElementRef | undefined;
matSelect = inject(MatSelect);
input = viewChild.required<ElementRef>('input');

matOpenedChange = toSignal(this.matSelect.openedChange.pipe(delay(1)));
onMatOpenedChange = effect(() => {
if (this.matOpenedChange()) {
this.focus();
} else {
this.control.reset(null, { emitEvent: false });
}
});

get value() {
return this.control.value;
Expand All @@ -85,35 +97,18 @@ export class SearchOptionsComponent implements OnInit, OnDestroy {
this.control.setValue(v);
}

constructor(private matSelect: MatSelect) {}

ngOnInit(): void {
this.matSelect.openedChange
.pipe(takeUntil(this.destroy), delay(1))
.subscribe((opened) => {
if (opened) {
this.focus();
} else {
this.control.reset(null, { emitEvent: false });
}
});
}

focus() {
// save and restore scrollTop of panel, since it will be reset by focus()
// note: this is hacky
const panel = this.matSelect.panel.nativeElement;
const scrollTop = panel.scrollTop;

// focus
this.input?.nativeElement?.focus();
this.input().nativeElement.focus();

panel.scrollTop = scrollTop;
}

ngOnDestroy(): void {
this.destroy.next();
}
clear() {
this.control.reset();
}
Expand Down
25 changes: 22 additions & 3 deletions apps/example-app/src/app/examples/examples-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
import { NgModule } from '@angular/core';

import { RouterModule, Routes } from '@angular/router';

import { ExamplesComponent } from './examples.component';

const routes: Routes = [
{
path: '',
component: ExamplesComponent,
},
{
path: 'signals',
children: [
{
path: '',
loadComponent: () =>
import('./signals/signal-examples.component').then(
(m) => m.SignalExamplesComponent,
),
},
{
path: 'product-list-paginated',
loadComponent: () =>
import(
'./signals/product-list-paginated-page/signal-product-list-paginated-page-container.component'
).then((m) => m.SignalProductListPaginatedPageContainerComponent),
},
],
},
{
path: 'product-list',
loadComponent: () =>
import('./product-list-page/product-list-page-container.component').then(
(m) => m.ProductListPageContainerComponent
(m) => m.ProductListPageContainerComponent,
),
},
{
Expand All @@ -33,7 +52,7 @@ const routes: Routes = [
path: 'product-shop',
loadChildren: () =>
import('./product-shop-page/product-shop-page-routing.module').then(
(m) => m.ProductShopPageRoutingModule
(m) => m.ProductShopPageRoutingModule,
),
},
{
Expand Down
14 changes: 10 additions & 4 deletions apps/example-app/src/app/examples/examples.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { MatDividerModule } from '@angular/material/divider';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatLineModule } from '@angular/material/core';
import { RouterLink } from '@angular/router';
import { MatDividerModule } from '@angular/material/divider';
import { MatListModule } from '@angular/material/list';
import { MatCardModule } from '@angular/material/card';
import { RouterLink } from '@angular/router';

@Component({
selector: 'ngrx-traits-examples',
Expand All @@ -14,6 +14,12 @@ import { MatCardModule } from '@angular/material/card';
</mat-card-header>
<mat-card-content>
<mat-list>
<mat-list-item [routerLink]="'signals'">
<div matListItemTitle><b>Signal examples</b></div>
<div matListItemLine>
Examples using ngrx-signals and ngrx-traits/signals
</div>
</mat-list-item>
<mat-list-item [routerLink]="'product-list'">
<div matListItemTitle><b>Simple List</b></div>
<div matListItemLine>
Expand Down
9 changes: 5 additions & 4 deletions apps/example-app/src/app/examples/services/product.service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Product, ProductDetail } from '../models';
import { delay } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';

import { Product, ProductDetail } from '../models';

@Injectable({ providedIn: 'root' })
export class ProductService {
constructor(private httpClient: HttpClient) {}

getProducts(options?: {
search?: string | undefined;
sortColumn?: keyof Product | undefined;
sortColumn?: keyof Product | string | undefined;
sortAscending?: boolean | undefined;
skip?: number | undefined;
take?: number | undefined;
}) {
return this.httpClient
.get<{
resultList: Product[];
total?: number;
total: number;
}>('/products', {
params: { ...options, search: options?.search ?? '' },
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Example using trait to load a product list with remote filtering and
sorting and pagination
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Sort } from '@ngrx-traits/common';

import { ProductListComponent } from '../../components/product-list/product-list.component';
import { ProductSearchFormComponent } from '../../components/product-search-form/product-search-form.component';
import { Product, ProductFilter } from '../../models';
import { ProductsLocalStore } from './product.store';

@Component({
selector: 'ngrx-traits-product-list-example-container',
template: `
<mat-card>
<mat-card-header>
<mat-card-title>Product List</mat-card-title>
</mat-card-header>
<mat-card-content>
<product-search-form
[searchProduct]="store.productsFilter()"
(searchProductChange)="filter($event)"
></product-search-form>
@if (store.productsLoading()) {
<mat-spinner></mat-spinner>
} @else {
<product-list
[list]="store.productsCurrentPage().entities"
[selectedProduct]="store.productsSelectedEntity()"
[selectedSort]="{
active: $any(store.productsSort().field),
direction: store.productsSort().direction
}"
(selectProduct)="select($event)"
(sort)="sort($event)"
></product-list>
<!-- [selectedSort]="store.productsSort()" -->
<mat-paginator
[length]="store.productsCurrentPage().total"
[pageSize]="store.productsCurrentPage().pageSize"
[pageIndex]="store.productsCurrentPage().pageIndex"
(page)="loadPage($event)"
></mat-paginator>
}
</mat-card-content>
<mat-card-actions [align]="'end'">
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="
!store.productsSelectedEntity() || store.checkoutLoading()
"
(click)="checkout()"
>
@if (store.checkoutLoading()) {
<mat-spinner [diameter]="20"></mat-spinner>
}
<span>CHECKOUT</span>
</button>
</mat-card-actions>
</mat-card>
`,
styles: [
`
mat-card-content > mat-spinner {
margin: 10px auto;
}
mat-card-actions mat-spinner {
display: inline-block;
margin-right: 5px;
}
`,
],
standalone: true,
imports: [
MatCardModule,
ProductSearchFormComponent,
MatProgressSpinnerModule,
ProductListComponent,
MatPaginatorModule,
MatButtonModule,
AsyncPipe,
],
})
export class ProductListPaginatedPageContainerComponent implements OnInit {
store = inject(ProductsLocalStore);

ngOnInit() {
this.store.loadProductDetail;
this.store.productsFilter;
// this.store.dispatch(ProductActions.loadProductsUsingRouteQueryParams());
}

select({ id }: Product) {
this.store.selectProductsEntity({ id });
}

checkout() {
this.store.checkout();
}

filter(filter: ProductFilter | undefined) {
filter && this.store.filterProductsEntities({ filter });
}

sort(sort: Sort<Product>) {
this.store.sortProductsEntities({
sort: { field: sort.active as string, direction: sort.direction },
});
}

loadPage($event: PageEvent) {
this.store.loadProductsPage({ pageIndex: $event.pageIndex });
}
}
Loading

0 comments on commit 92b52eb

Please sign in to comment.