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 29dc0c3f5..99c62606b 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,16 +1,15 @@ import { NgFor, NgIf } from '@angular/common'; import { Component, OnInit, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } 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 { LetDirective } from '@ngrx/component'; -import { provideComponentStore } from '@ngrx/component-store'; -import { debounceTime, distinctUntilChanged, skipWhile, tap } from 'rxjs'; +import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs'; import { Photo } from '../photo.model'; import { PhotoStore } from './photos.store'; - @Component({ selector: 'app-photos', standalone: true, @@ -32,85 +31,82 @@ import { PhotoStore } from './photos.store'; - + @if (store; as store) {
- Page :{{ vm.page }} / {{ vm.pages }} + Page :{{ store.page() }} / {{ store.pages() }}
- - - + @if (store.loading()) { + + } + + @if (store.photos() && store.photos().length > 0) { +
    + @for (photo of store.photos(); track photo.id) { +
  • + + {{ photo.title }} + +
  • + } +
+ } @else {
No Photos found. Type a search word.
-
+ }
-
+ } `, - providers: [provideComponentStore(PhotoStore)], + providers: [PhotoStore], host: { class: 'p-5 block', }, }) export default class PhotosComponent implements OnInit { store = inject(PhotoStore); - readonly vm$ = this.store.vm$.pipe( - tap(({ search }) => { - if (!this.formInit) { - this.search.setValue(search); - this.formInit = true; - } - }), - ); + searchTerm = new FormControl(this.store.searchTerm()); - private formInit = false; - search = new FormControl(); + private searchInput = this.searchTerm.valueChanges.pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntilDestroyed(), + ); ngOnInit(): void { - this.store.search( - this.search.valueChanges.pipe( - skipWhile(() => !this.formInit), - debounceTime(300), - distinctUntilChanged(), - ), - ); - } - - trackById(index: number, photo: Photo) { - return photo.id; + this.searchInput + .pipe( + filter((searchInputValue) => !!searchInputValue), + tap((searchInputValue) => { + this.store.search(searchInputValue!); + }), + ) + .subscribe(); } encode(photo: 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 01de2d4ec..936068716 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,10 +1,14 @@ -import { Injectable, inject } from '@angular/core'; +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; import { - ComponentStore, - OnStateInit, - OnStoreInit, - tapResponse, -} from '@ngrx/component-store'; + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { pipe } from 'rxjs'; import { filter, mergeMap, tap } from 'rxjs/operators'; import { Photo } from '../photo.model'; @@ -14,7 +18,7 @@ const PHOTO_STATE_KEY = 'photo_search'; export interface PhotoState { photos: Photo[]; - search: string; + searchTerm: string; page: number; pages: number; loading: boolean; @@ -23,113 +27,76 @@ export interface PhotoState { const initialState: PhotoState = { photos: [], - search: '', + searchTerm: '', 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$, +export const PhotoStore = signalStore( + withState(initialState), + withComputed(({ page, pages }) => ({ + endOfPage: computed(() => page() === pages()), + })), + withHooks({ + onInit(store) { + const savedJSONState = localStorage.getItem(PHOTO_STATE_KEY); + if (savedJSONState) { + const savedState = JSON.parse(savedJSONState); + patchState(store, () => { + return { + searchTerm: savedState.searchTerm, + page: savedState.page, + }; + }); + } }, - { 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 }), - ), - ), + onDestroy() { + console.log('destroying store'); + }, + }), + withMethods((state, photoService = inject(PhotoService)) => ({ + searchPhotos: rxMethod( + pipe( + filter(() => state.searchTerm().length >= 3), + tap(() => patchState(state, { loading: true, error: '' })), + mergeMap(() => { + const searchTerm = state.searchTerm(); + const page = state.page(); + return photoService.searchPublicPhotos(searchTerm, page).pipe( + tapResponse({ + next: ({ photos: { photo, pages } }) => { + patchState(state, { + loading: false, + pages, + photos: photo, + }); + localStorage.setItem( + PHOTO_STATE_KEY, + JSON.stringify({ searchTerm, page }), + ); + }, + error: (error: unknown) => + patchState(state, { error, loading: false }), + }), + ); + }), ), ), - ); -} + })), + withMethods((state) => ({ + search(search: string) { + patchState(state, { searchTerm: search, page: 1 }); + state.searchPhotos(); + }, + nextPage() { + patchState(state, { page: state.page() + 1 }); + state.searchPhotos(); + }, + previousPage() { + patchState(state, { page: state.page() - 1 }); + state.searchPhotos(); + }, + })), +); diff --git a/package.json b/package.json index b36ec4f46..c63745315 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "rxjs": "7.8.1", "tailwindcss": "3.4.3", "tslib": "^2.3.0", - "zone.js": "0.14.2" + "zone.js": "0.14.2", + "@ngrx/signals": "17.1.1" }, "devDependencies": { "@angular-devkit/build-angular": "18.1.1",