Skip to content

Commit 5ca1eab

Browse files
committed
feat(Answer:30): solution
1 parent 5697f25 commit 5ca1eab

4 files changed

Lines changed: 146 additions & 188 deletions

File tree

apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts

Lines changed: 51 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
1-
import { AsyncPipe } from '@angular/common';
21
import { Component, inject, OnInit } from '@angular/core';
2+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
33
import { FormControl, ReactiveFormsModule } from '@angular/forms';
44
import { MatFormFieldModule } from '@angular/material/form-field';
55
import { MatInputModule } from '@angular/material/input';
66
import { MatProgressBarModule } from '@angular/material/progress-bar';
77
import { RouterLinkWithHref } from '@angular/router';
8-
import { provideComponentStore } from '@ngrx/component-store';
9-
import {
10-
debounceTime,
11-
distinctUntilChanged,
12-
Observable,
13-
skipWhile,
14-
tap,
15-
} from 'rxjs';
8+
import { debounceTime, distinctUntilChanged, startWith } from 'rxjs';
169
import { Photo } from '../photo.model';
1710
import { PhotoStore } from './photos.store';
1811

@@ -24,7 +17,6 @@ import { PhotoStore } from './photos.store';
2417
MatProgressBarModule,
2518
MatInputModule,
2619
RouterLinkWithHref,
27-
AsyncPipe,
2820
],
2921
template: `
3022
<h2 class="mb-2 text-xl">Photos</h2>
@@ -38,90 +30,66 @@ import { PhotoStore } from './photos.store';
3830
placeholder="find a photo" />
3931
</mat-form-field>
4032
41-
@let vm = vm$ | async;
42-
@if (vm) {
43-
<section class="flex flex-col">
44-
<section class="flex items-center gap-3">
45-
<button
46-
[disabled]="vm.page === 1"
47-
[class.bg-gray-400]="vm.page === 1"
48-
class="rounded-md border p-3 text-xl"
49-
(click)="store.previousPage()">
50-
<
51-
</button>
52-
<button
53-
[disabled]="vm.endOfPage"
54-
[class.bg-gray-400]="vm.endOfPage"
55-
class="rounded-md border p-3 text-xl"
56-
(click)="store.nextPage()">
57-
>
58-
</button>
59-
Page :{{ vm.page }} / {{ vm.pages }}
60-
</section>
61-
@if (vm.loading) {
62-
<mat-progress-bar mode="query" class="mt-5"></mat-progress-bar>
63-
}
64-
@if (vm.photos && vm.photos.length > 0) {
65-
<ul class="flex flex-wrap gap-4">
66-
@for (
67-
photo of vm.photos;
68-
track photo.id;
69-
let i = $index
70-
) {
71-
<li>
72-
<a routerLink="detail" [queryParams]="{ photo: encode(photo) }">
73-
<img
74-
src="{{ photo.url_q }}"
75-
alt="{{ photo.title }}"
76-
class="image" />
77-
</a>
78-
</li>
79-
}
80-
</ul>
81-
} @else {
82-
<div>No Photos found. Type a search word.</div>
83-
}
84-
<footer class="text-red-500">
85-
{{ vm.error }}
86-
</footer>
33+
<section class="flex flex-col">
34+
<section class="flex items-center gap-3">
35+
<button
36+
[disabled]="store.page() === 1"
37+
[class.bg-gray-400]="store.page() === 1"
38+
class="rounded-md border p-3 text-xl"
39+
(click)="store.previousPage()">
40+
<
41+
</button>
42+
<button
43+
[disabled]="store.endOfPage()"
44+
[class.bg-gray-400]="store.endOfPage()"
45+
class="rounded-md border p-3 text-xl"
46+
(click)="store.nextPage()">
47+
>
48+
</button>
49+
Page :{{ store.page() }} / {{ store.pages() }}
8750
</section>
88-
}
51+
@if (store.loading()) {
52+
<mat-progress-bar mode="query" class="mt-5"></mat-progress-bar>
53+
}
54+
@if (store.photos().length > 0) {
55+
<ul class="flex flex-wrap gap-4">
56+
@for (photo of store.photos(); track photo.id; let i = $index) {
57+
<li>
58+
<a routerLink="detail" [queryParams]="{ photo: encode(photo) }">
59+
<img
60+
src="{{ photo.url_q }}"
61+
alt="{{ photo.title }}"
62+
class="image" />
63+
</a>
64+
</li>
65+
}
66+
</ul>
67+
} @else {
68+
<div>No Photos found. Type a search word.</div>
69+
}
70+
<footer class="text-red-500">
71+
{{ store.error() }}
72+
</footer>
73+
</section>
8974
`,
90-
providers: [provideComponentStore(PhotoStore)],
75+
providers: [PhotoStore],
9176
host: {
9277
class: 'p-5 block',
9378
},
9479
})
9580
export default class PhotosComponent implements OnInit {
9681
store = inject(PhotoStore);
97-
readonly vm$: Observable<{
98-
photos: Photo[];
99-
search: string;
100-
page: number;
101-
pages: number;
102-
endOfPage: boolean;
103-
loading: boolean;
104-
error: unknown;
105-
}> = this.store.vm$.pipe(
106-
tap(({ search }) => {
107-
if (!this.formInit) {
108-
this.search.setValue(search);
109-
this.formInit = true;
110-
}
111-
}),
112-
);
11382

114-
private formInit = false;
115-
search = new FormControl();
83+
search = new FormControl<string>(this.store.search(), { nonNullable: true });
84+
searchTerm = this.search.valueChanges.pipe(
85+
startWith(this.search.value),
86+
debounceTime(300),
87+
distinctUntilChanged(),
88+
takeUntilDestroyed(),
89+
);
11690

11791
ngOnInit(): void {
118-
this.store.search(
119-
this.search.valueChanges.pipe(
120-
skipWhile(() => !this.formInit),
121-
debounceTime(300),
122-
distinctUntilChanged(),
123-
),
124-
);
92+
this.searchTerm.subscribe((search) => this.store.setSearch(search));
12593
}
12694

12795
encode(photo: Photo) {
Lines changed: 74 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { inject, Injectable } from '@angular/core';
2-
import {
3-
ComponentStore,
4-
OnStateInit,
5-
OnStoreInit,
6-
} from '@ngrx/component-store';
1+
import { computed, inject, InjectionToken } from '@angular/core';
2+
import { rxResource } from '@angular/core/rxjs-interop';
73
import { tapResponse } from '@ngrx/operators';
8-
import { pipe } from 'rxjs';
9-
import { filter, mergeMap, tap } from 'rxjs/operators';
4+
import {
5+
patchState,
6+
signalStore,
7+
withComputed,
8+
withHooks,
9+
withMethods,
10+
withProps,
11+
withState,
12+
} from '@ngrx/signals';
13+
import { of } from 'rxjs';
1014
import { Photo } from '../photo.model';
1115
import { PhotoService } from '../photos.service';
1216

@@ -17,107 +21,37 @@ export interface PhotoState {
1721
search: string;
1822
page: number;
1923
pages: number;
20-
loading: boolean;
21-
error: unknown;
2224
}
2325

2426
const initialState: PhotoState = {
2527
photos: [],
2628
search: '',
2729
page: 1,
2830
pages: 1,
29-
loading: false,
30-
error: '',
3131
};
3232

33-
@Injectable()
34-
export class PhotoStore
35-
extends ComponentStore<PhotoState>
36-
implements OnStoreInit, OnStateInit
37-
{
38-
private photoService = inject(PhotoService);
39-
40-
private readonly photos$ = this.select((s) => s.photos);
41-
private readonly search$ = this.select((s) => s.search);
42-
private readonly page$ = this.select((s) => s.page);
43-
private readonly pages$ = this.select((s) => s.pages);
44-
private readonly error$ = this.select((s) => s.error);
45-
private readonly loading$ = this.select((s) => s.loading);
46-
47-
private readonly endOfPage$ = this.select(
48-
this.page$,
49-
this.pages$,
50-
(page, pages) => page === pages,
51-
);
52-
53-
readonly vm$ = this.select(
54-
{
55-
photos: this.photos$,
56-
search: this.search$,
57-
page: this.page$,
58-
pages: this.pages$,
59-
endOfPage: this.endOfPage$,
60-
loading: this.loading$,
61-
error: this.error$,
62-
},
63-
{ debounce: true },
64-
);
65-
66-
ngrxOnStoreInit() {
67-
const savedJSONState = localStorage.getItem(PHOTO_STATE_KEY);
68-
if (savedJSONState === null) {
69-
this.setState(initialState);
70-
} else {
71-
const savedState = JSON.parse(savedJSONState);
72-
this.setState({
73-
...initialState,
74-
search: savedState.search,
75-
page: savedState.page,
76-
});
77-
}
78-
}
33+
const PHOTO_STATE = new InjectionToken<PhotoState>('PhotoState', {
34+
factory: () => initialState,
35+
});
7936

80-
ngrxOnStateInit() {
81-
this.searchPhotos(
82-
this.select({
83-
search: this.search$,
84-
page: this.page$,
37+
export const PhotoStore = signalStore(
38+
withState(() => inject(PHOTO_STATE)),
39+
withProps((store, photoService = inject(PhotoService)) => ({
40+
photosResource: rxResource({
41+
params: () => ({
42+
search: store.search(),
43+
page: store.page(),
8544
}),
86-
);
87-
}
88-
89-
readonly search = this.updater(
90-
(state, search: string): PhotoState => ({
91-
...state,
92-
search,
93-
page: 1,
94-
}),
95-
);
96-
97-
readonly nextPage = this.updater(
98-
(state): PhotoState => ({
99-
...state,
100-
page: state.page + 1,
101-
}),
102-
);
103-
104-
readonly previousPage = this.updater(
105-
(state): PhotoState => ({
106-
...state,
107-
page: state.page - 1,
108-
}),
109-
);
45+
stream: ({ params: { search, page } }) => {
46+
console.log('Searching for:', search, 'Page:', page);
47+
if (search !== '' && (search == undefined || search.length < 3)) {
48+
return of(undefined);
49+
}
11050

111-
readonly searchPhotos = this.effect<{ search: string; page: number }>(
112-
pipe(
113-
filter(({ search }) => search.length >= 3),
114-
tap(() => this.patchState({ loading: true, error: '' })),
115-
mergeMap(({ search, page }) =>
116-
this.photoService.searchPublicPhotos(search, page).pipe(
117-
tapResponse(
118-
({ photos: { photo, pages } }) => {
119-
this.patchState({
120-
loading: false,
51+
return photoService.searchPublicPhotos(search, page).pipe(
52+
tapResponse({
53+
next: ({ photos: { photo, pages } }) => {
54+
patchState(store, {
12155
photos: photo,
12256
pages,
12357
});
@@ -126,10 +60,46 @@ export class PhotoStore
12660
JSON.stringify({ search, page }),
12761
);
12862
},
129-
(error: unknown) => this.patchState({ error, loading: false }),
130-
),
131-
),
132-
),
133-
),
134-
);
135-
}
63+
error: (error: unknown) => {
64+
console.error('Photo search error:', error);
65+
},
66+
}),
67+
);
68+
},
69+
}),
70+
})),
71+
withComputed(({ photosResource, page, pages }) => ({
72+
loading: computed(() => photosResource.isLoading()),
73+
error: computed(() => photosResource.error()),
74+
endOfPage: computed(() => page() === pages()),
75+
})),
76+
withMethods((store) => ({
77+
setSearch(search: string) {
78+
patchState(store, { search, page: 1 });
79+
},
80+
nextPage(): void {
81+
patchState(store, (state) => ({
82+
...state,
83+
page: state.page + 1,
84+
}));
85+
},
86+
previousPage(): void {
87+
patchState(store, (state) => ({
88+
...state,
89+
page: state.page - 1,
90+
}));
91+
},
92+
})),
93+
withHooks((store) => ({
94+
onInit() {
95+
const savedJSONState = localStorage.getItem(PHOTO_STATE_KEY);
96+
if (savedJSONState !== null) {
97+
const savedState = JSON.parse(savedJSONState);
98+
patchState(store, {
99+
search: savedState.search,
100+
page: savedState.page,
101+
});
102+
}
103+
},
104+
})),
105+
);

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@ngneat/falso": "7.2.0",
3636
"@ngrx/component-store": "21.0.0",
3737
"@ngrx/operators": "21.0.0",
38+
"@ngrx/signals": "^21.1.0",
3839
"@nx/angular": "22.5.4",
3940
"@swc/helpers": "0.5.19",
4041
"@tanstack/angular-query-experimental": "5.90.16",
@@ -76,6 +77,7 @@
7677
"@swc/cli": "0.7.10",
7778
"@swc/core": "1.15.8",
7879
"@tailwindcss/forms": "^0.5.10",
80+
"@tailwindcss/postcss": "4.2.1",
7981
"@testing-library/angular": "19.0.0",
8082
"@testing-library/cypress": "10.1.0",
8183
"@testing-library/jest-dom": "6.9.1",
@@ -108,7 +110,6 @@
108110
"nx": "22.5.4",
109111
"playwright": "1.58.2",
110112
"postcss": "^8.4.5",
111-
"@tailwindcss/postcss": "4.2.1",
112113
"postcss-import": "~14.1.0",
113114
"postcss-preset-env": "~7.5.0",
114115
"postcss-url": "~10.1.3",

0 commit comments

Comments
 (0)