diff --git a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts index 7ba115027..51c142818 100644 --- a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts +++ b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts @@ -1,30 +1,21 @@ -import { AsyncPipe } from '@angular/common'; -import { Component, inject, OnInit } from '@angular/core'; -import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { RouterLinkWithHref } from '@angular/router'; -import { provideComponentStore } from '@ngrx/component-store'; -import { - debounceTime, - distinctUntilChanged, - Observable, - skipWhile, - tap, -} from 'rxjs'; + import { Photo } from '../photo.model'; -import { PhotoStore } from './photos.store'; +import { PhotosStore } from './photos.store'; @Component({ selector: 'app-photos', imports: [ - ReactiveFormsModule, + FormsModule, MatFormFieldModule, MatProgressBarModule, MatInputModule, RouterLinkWithHref, - AsyncPipe, ], template: `

Photos

@@ -34,93 +25,61 @@ import { PhotoStore } from './photos.store'; - @let vm = vm$ | async; -
-
- - - Page :{{ vm.page }} / {{ vm.pages }} -
- @if (vm.loading) { - - } - @if (vm.photos && vm.photos.length > 0) { - - } @else { -
No Photos found. Type a search word.
- } - +
+
+ + + Page :{{ store.page() }} / {{ store.pages() }}
+ @if (store.loading()) { + + } + @let photos = store.data(); + @if (photos && photos.length > 0) { + + } @else { +
No Photos found. Type a search word.
+ } +
+ {{ store.error() }} +
+
`, - providers: [provideComponentStore(PhotoStore)], + providers: [PhotosStore], host: { class: 'p-5 block', }, }) -export default class PhotosComponent implements OnInit { - store = inject(PhotoStore); - readonly vm$: Observable<{ - photos: Photo[]; - search: string; - page: number; - pages: number; - endOfPage: boolean; - loading: boolean; - error: unknown; - }> = this.store.vm$.pipe( - tap(({ search }) => { - if (!this.formInit) { - this.search.setValue(search); - this.formInit = true; - } - }), - ); - - private formInit = false; - search = new FormControl(); - - ngOnInit(): void { - this.store.search( - this.search.valueChanges.pipe( - skipWhile(() => !this.formInit), - debounceTime(300), - distinctUntilChanged(), - ), - ); - } +export default class PhotosComponent { + readonly store = inject(PhotosStore); encode(photo: Photo) { return encodeURIComponent(JSON.stringify(photo)); diff --git a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts index f1315e87e..ef37876b8 100644 --- a/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts +++ b/apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts @@ -1,135 +1,41 @@ -import { inject, Injectable } from '@angular/core'; -import { - ComponentStore, - OnStateInit, - OnStoreInit, -} from '@ngrx/component-store'; -import { tapResponse } from '@ngrx/operators'; -import { pipe } from 'rxjs'; -import { filter, mergeMap, tap } from 'rxjs/operators'; -import { Photo } from '../photo.model'; +import { withStorageSync } from '@angular-architects/ngrx-toolkit'; +import { inject } from '@angular/core'; +import { signalStore, withComputed, withHooks } from '@ngrx/signals'; import { PhotoService } from '../photos.service'; - -const PHOTO_STATE_KEY = 'photo_search'; - -export interface PhotoState { - photos: Photo[]; - search: string; - page: number; - pages: number; - loading: boolean; - error: unknown; -} - -const initialState: PhotoState = { - photos: [], - search: '', - page: 1, - pages: 1, - loading: false, - error: '', -}; - -@Injectable() -export class PhotoStore - extends ComponentStore - implements OnStoreInit, OnStateInit -{ - private photoService = inject(PhotoService); - - private readonly photos$ = this.select((s) => s.photos); - private readonly search$ = this.select((s) => s.search); - private readonly page$ = this.select((s) => s.page); - private readonly pages$ = this.select((s) => s.pages); - private readonly error$ = this.select((s) => s.error); - private readonly loading$ = this.select((s) => s.loading); - - private readonly endOfPage$ = this.select( - this.page$, - this.pages$, - (page, pages) => page === pages, - ); - - readonly vm$ = this.select( - { - photos: this.photos$, - search: this.search$, - page: this.page$, - pages: this.pages$, - endOfPage: this.endOfPage$, - loading: this.loading$, - error: this.error$, +import { withRemoteResource } from '../store-feature/with-remote-resource.feature'; +import { withRequestStatus } from '../store-feature/with-request-status.feature'; +import { withSearchAndPaging } from '../store-feature/with-search-and-paging.feature'; + +export const PHOTO_STATE_KEY = 'photo_search'; + +export const PhotosStore = signalStore( + withRequestStatus(), + withSearchAndPaging(), + withStorageSync({ + key: PHOTO_STATE_KEY, + autoSync: false, + select: ({ page, search }) => ({ page, search }), + }), + withRemoteResource({ + syncToStorage: true, + loaderFnFactory: () => { + const service = inject(PhotoService); + return service.searchPublicPhotos.bind(service); }, - { debounce: true }, - ); - - ngrxOnStoreInit() { - const savedJSONState = localStorage.getItem(PHOTO_STATE_KEY); - if (savedJSONState === null) { - this.setState(initialState); - } else { - const savedState = JSON.parse(savedJSONState); - this.setState({ - ...initialState, - search: savedState.search, - page: savedState.page, - }); - } - } - - ngrxOnStateInit() { - this.searchPhotos( - this.select({ - search: this.search$, - page: this.page$, - }), - ); - } - - readonly search = this.updater( - (state, search: string): PhotoState => ({ - ...state, - search, - page: 1, - }), - ); - - readonly nextPage = this.updater( - (state): PhotoState => ({ - ...state, - page: state.page + 1, - }), - ); - - readonly previousPage = this.updater( - (state): PhotoState => ({ - ...state, - page: state.page - 1, - }), - ); - - readonly searchPhotos = this.effect<{ search: string; page: number }>( - pipe( - filter(({ search }) => search.length >= 3), - tap(() => this.patchState({ loading: true, error: '' })), - mergeMap(({ search, page }) => - this.photoService.searchPublicPhotos(search, page).pipe( - tapResponse( - ({ photos: { photo, pages } }) => { - this.patchState({ - loading: false, - photos: photo, - pages, - }); - localStorage.setItem( - PHOTO_STATE_KEY, - JSON.stringify({ search, page }), - ); - }, - (error: unknown) => this.patchState({ error, loading: false }), - ), - ), - ), - ), - ); -} + }), + withComputed(({ page, pages }) => ({ + endOfPage: () => page() === pages(), + })), + withHooks((store) => { + return { + onInit() { + store.readFromStorage(); + + store.loadResource(() => ({ + search: store.search(), + page: store.page(), + })); + }, + }; + }), +); diff --git a/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts b/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts index c540118f6..021d62548 100644 --- a/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts +++ b/apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts @@ -1,6 +1,6 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { Observable } from 'rxjs'; +import { inject, Injectable } from '@angular/core'; +import { map, Observable } from 'rxjs'; import { Photo } from './photo.model'; export interface FlickrAPIResponse { @@ -21,10 +21,9 @@ export class PhotoService { public searchPublicPhotos( searchTerm: string, page: number, - ): Observable { - return this.http.get( - 'https://www.flickr.com/services/rest/', - { + ): Observable<{ data: Photo[]; pages: number }> { + return this.http + .get('https://www.flickr.com/services/rest/', { params: { tags: searchTerm, method: 'flickr.photos.search', @@ -37,7 +36,11 @@ export class PhotoService { extras: 'tags,date_taken,owner_name,url_q,url_m', api_key: 'c3050d39a5bb308d9921bef0e15c437d', }, - }, - ); + }) + .pipe( + map(({ photos: { pages, photo } }) => { + return { pages, data: photo }; + }), + ); } } diff --git a/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-remote-resource.feature.ts b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-remote-resource.feature.ts new file mode 100644 index 000000000..569d27f90 --- /dev/null +++ b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-remote-resource.feature.ts @@ -0,0 +1,92 @@ +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStoreFeature, + type, + withMethods, + withState, +} from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { + debounceTime, + distinctUntilChanged, + filter, + Observable, + pipe, + switchMap, + tap, +} from 'rxjs'; +import { + RequestStatusState, + setError, + setLoading, +} from './with-request-status.feature'; + +export type LoaderFn = ( + search: string, + page: number, +) => Observable<{ data: TData[]; pages: number }>; + +export interface RemoteResourceState { + data: TData; + pages: number; +} + +export function withRemoteResource({ + syncToStorage = true, + loaderFnFactory, +}: { + syncToStorage?: boolean; + loaderFnFactory: () => LoaderFn; +}) { + return signalStoreFeature( + { + state: type(), + methods: type<{ writeToStorage?: () => void }>(), + }, + withState({ + data: [] as TData[], + pages: 1, + }), + withMethods((store) => { + const loaderFn = loaderFnFactory(); + + return { + loadResource: rxMethod<{ search: string; page: number }>( + pipe( + filter(({ search }) => search.length >= 3), + debounceTime(300), + distinctUntilChanged(), + tap(() => patchState(store, setLoading(true))), + switchMap(({ search, page }) => { + return loaderFn(search, page).pipe( + tapResponse({ + next({ data, pages }) { + patchState( + store, + { + data, + pages, + }, + setLoading(false), + ); + + if ( + syncToStorage && + typeof store.writeToStorage === 'function' + ) { + store?.writeToStorage(); + } + }, + error(error) { + patchState(store, setError(error)); + }, + }), + ); + }), + ), + ), + }; + }), + ); +} diff --git a/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-request-status.feature.ts b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-request-status.feature.ts new file mode 100644 index 000000000..1f8b41b55 --- /dev/null +++ b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-request-status.feature.ts @@ -0,0 +1,23 @@ +import { signalStoreFeature, withState } from '@ngrx/signals'; + +export interface RequestStatusState { + loading: boolean; + error: unknown; +} + +const initialState: RequestStatusState = { + loading: false, + error: '', +}; + +export function withRequestStatus() { + return signalStoreFeature(withState(initialState)); +} + +export function setLoading(loading: boolean): RequestStatusState { + return { loading, error: '' }; +} + +export function setError(error: unknown): RequestStatusState { + return { error, loading: false }; +} diff --git a/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-search-and-paging.feature.ts b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-search-and-paging.feature.ts new file mode 100644 index 000000000..2faea92da --- /dev/null +++ b/apps/signal/30-interop-rxjs-signal/src/app/store-feature/with-search-and-paging.feature.ts @@ -0,0 +1,33 @@ +import { + patchState, + signalStoreFeature, + withMethods, + withState, +} from '@ngrx/signals'; + +export interface SearchAndPagingState { + search: string; + page: number; +} + +export function withSearchAndPaging() { + return signalStoreFeature( + withState({ + search: '', + page: 1, + }), + withMethods((store) => { + return { + setSearch: (search: string) => { + patchState(store, { search, page: 1 }); + }, + nextPage: () => { + patchState(store, ({ page }) => ({ page: page + 1 })); + }, + previousPage: () => { + patchState(store, ({ page }) => ({ page: page - 1 })); + }, + }; + }), + ); +} diff --git a/package.json b/package.json index 30b08fad3..a4bae1fca 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dependencies": { "@actions/core": "^1.10.1", "@actions/github": "^6.0.0", + "@angular-architects/ngrx-toolkit": "^21.0.1", "@angular/animations": "21.2.2", "@angular/cdk": "21.2.1", "@angular/common": "21.2.2", @@ -35,6 +36,7 @@ "@ngneat/falso": "7.2.0", "@ngrx/component-store": "21.0.0", "@ngrx/operators": "21.0.0", + "@ngrx/signals": "^21.0.1", "@nx/angular": "22.5.4", "@swc/helpers": "0.5.19", "@tanstack/angular-query-experimental": "5.90.16", @@ -50,7 +52,6 @@ "@angular-devkit/build-angular": "21.2.1", "@angular-devkit/core": "21.2.1", "@angular-devkit/schematics": "21.2.1", - "angular-eslint": "21.3.0", "@angular/build": "21.2.1", "@angular/cli": "21.2.1", "@angular/compiler-cli": "21.2.2", @@ -89,6 +90,7 @@ "@typescript-eslint/utils": "^8.40.0", "@vitest/browser-playwright": "4.1.0-beta.4", "all-contributors-cli": "^6.26.1", + "angular-eslint": "21.3.0", "autoprefixer": "^10.4.0", "cypress": "15.8.1", "eslint": "^9.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd1162dad..64025e6f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@actions/github': specifier: ^6.0.0 version: 6.0.1 + '@angular-architects/ngrx-toolkit': + specifier: ^21.0.1 + version: 21.0.1(@angular/common@21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(@ngrx/signals@21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(rxjs@7.8.1) '@angular/animations': specifier: 21.2.2 version: 21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0)) @@ -59,6 +62,9 @@ importers: '@ngrx/operators': specifier: 21.0.0 version: 21.0.0(rxjs@7.8.1) + '@ngrx/signals': + specifier: ^21.0.1 + version: 21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1) '@nx/angular': specifier: 22.5.4 version: 22.5.4(a5eeeec2ccebf74e909b99894b49c4c1) @@ -421,6 +427,18 @@ packages: '@angular-devkit/architect': '>=0.1500.0 < 0.2200.0' vitest: ^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0 + '@angular-architects/ngrx-toolkit@21.0.1': + resolution: {integrity: sha512-mbwNxO+HIhf5ocUdgj7ywrSCpLLzgAg+whktDDhBoVQfLdlTZKoI6NRuOAjxTksaaok85Wv6+nHGBRuJLDIjsQ==} + peerDependencies: + '@angular/common': ^21.0.0 + '@angular/core': ^21.0.0 + '@ngrx/signals': ^21.0.0 + '@ngrx/store': ^21.0.0 + rxjs: ^7.0.0 + peerDependenciesMeta: + '@ngrx/store': + optional: true + '@angular-devkit/architect@0.2101.0': resolution: {integrity: sha512-vnNAzWXwSRGTHk2K7woIQsj7WDYZp69Z3DBdlxkK0H08ymkJ/ELbhN0/AnIJNNtYCqEb57AH7Ro98n422beDuw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -3088,6 +3106,15 @@ packages: '@ngrx/schematics@19.0.1': resolution: {integrity: sha512-kH91X7zewB8niOeemk8NgbjnnW0crwqTl8gG2zZAySW3Q0yGnoPpVAc4Yxezm1/bKsLGLILvEAzzlQGKBrigVg==} + '@ngrx/signals@21.0.1': + resolution: {integrity: sha512-krmZDhgHrnmZrxfEJ41bp/aM8Mc55k5B2N7oCLT5w4M3YbOkbnWPkP6bBWMv4XPI+2rqVgkLRW6DaWLwoESaBw==} + peerDependencies: + '@angular/core': ^21.0.0 + rxjs: ^6.5.3 || ^7.4.0 + peerDependenciesMeta: + rxjs: + optional: true + '@ngtools/webpack@21.2.1': resolution: {integrity: sha512-HGRGTDmyo3IuxxEAVK9/QK2Nd/nwWIh/zcd2x6nmCxC7tdB0fwIhEZhWnUDyLP2QnDaEPeR4NnZCGTN89SWhGg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -11027,6 +11054,14 @@ snapshots: '@angular-devkit/architect': 0.2102.1(chokidar@5.0.0) vitest: 4.1.0-beta.4(@types/node@18.16.9)(@vitest/browser-playwright@4.1.0-beta.4)(jsdom@27.4.0)(vite@7.3.1(@types/node@18.16.9)(jiti@2.4.2)(less@4.5.1)(sass-embedded@1.96.0)(sass@1.97.1)(stylus@0.64.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@angular-architects/ngrx-toolkit@21.0.1(@angular/common@21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(@ngrx/signals@21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(rxjs@7.8.1)': + dependencies: + '@angular/common': 21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1) + '@angular/core': 21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0) + '@ngrx/signals': 21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1) + rxjs: 7.8.1 + tslib: 2.8.1 + '@angular-devkit/architect@0.2101.0(chokidar@5.0.0)': dependencies: '@angular-devkit/core': 21.1.0(chokidar@5.0.0) @@ -14624,6 +14659,13 @@ snapshots: '@ngrx/schematics@19.0.1': {} + '@ngrx/signals@21.0.1(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1)': + dependencies: + '@angular/core': 21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0) + tslib: 2.8.1 + optionalDependencies: + rxjs: 7.8.1 + '@ngtools/webpack@21.2.1(@angular/compiler-cli@21.2.2(@angular/compiler@21.2.2)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.105.2(@swc/core@1.15.8(@swc/helpers@0.5.19)))': dependencies: '@angular/compiler-cli': 21.2.2(@angular/compiler@21.2.2)(typescript@5.9.3)