Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 51 additions & 92 deletions apps/signal/30-interop-rxjs-signal/src/app/list/photos.component.ts
Original file line number Diff line number Diff line change
@@ -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: `
<h2 class="mb-2 text-xl">Photos</h2>
Expand All @@ -34,93 +25,61 @@ import { PhotoStore } from './photos.store';
<input
type="text"
matInput
[formControl]="search"
[ngModel]="store.search()"
(ngModelChange)="store.setSearch($event)"
placeholder="find a photo" />
</mat-form-field>

@let vm = vm$ | async;
<section class="flex flex-col">
<section class="flex items-center gap-3">
<button
[disabled]="vm.page === 1"
[class.bg-gray-400]="vm.page === 1"
class="rounded-md border p-3 text-xl"
(click)="store.previousPage()">
<
</button>
<button
[disabled]="vm.endOfPage"
[class.bg-gray-400]="vm.endOfPage"
class="rounded-md border p-3 text-xl"
(click)="store.nextPage()">
>
</button>
Page :{{ vm.page }} / {{ vm.pages }}
</section>
@if (vm.loading) {
<mat-progress-bar mode="query" class="mt-5"></mat-progress-bar>
}
@if (vm.photos && vm.photos.length > 0) {
<ul class="flex flex-wrap gap-4">
@for (
photo of vm.photos;
track photo.id;
let i = $index
) {
<li>
<a routerLink="detail" [queryParams]="{ photo: encode(photo) }">
<img
src="{{ photo.url_q }}"
alt="{{ photo.title }}"
class="image" />
</a>
</li>
}
</ul>
} @else {
<div>No Photos found. Type a search word.</div>
}
<footer class="text-red-500">
{{ vm.error }}
</footer>
<section class="flex flex-col">
<section class="flex items-center gap-3">
<button
[disabled]="store.page() === 1"
[class.bg-gray-400]="store.page() === 1"
class="rounded-md border p-3 text-xl"
(click)="store.previousPage()">
<
</button>
<button
[disabled]="store.endOfPage()"
[class.bg-gray-400]="store.endOfPage()"
class="rounded-md border p-3 text-xl"
(click)="store.nextPage()">
>
</button>
Page :{{ store.page() }} / {{ store.pages() }}
</section>
@if (store.loading()) {
<mat-progress-bar mode="query" class="mt-5"></mat-progress-bar>
}
@let photos = store.data();
@if (photos && photos.length > 0) {
<ul class="flex flex-wrap gap-4">
@for (photo of photos; track photo.id; let i = $index) {
<li>
<a routerLink="detail" [queryParams]="{ photo: encode(photo) }">
<img
src="{{ photo.url_q }}"
alt="{{ photo.title }}"
class="image" />
</a>
</li>
}
</ul>
} @else {
<div>No Photos found. Type a search word.</div>
}
<footer class="text-red-500">
{{ store.error() }}
</footer>
</section>
`,
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));
Expand Down
172 changes: 39 additions & 133 deletions apps/signal/30-interop-rxjs-signal/src/app/list/photos.store.ts
Original file line number Diff line number Diff line change
@@ -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<PhotoState>
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(),
}));
},
};
}),
);
19 changes: 11 additions & 8 deletions apps/signal/30-interop-rxjs-signal/src/app/photos.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -21,10 +21,9 @@ export class PhotoService {
public searchPublicPhotos(
searchTerm: string,
page: number,
): Observable<FlickrAPIResponse> {
return this.http.get<FlickrAPIResponse>(
'https://www.flickr.com/services/rest/',
{
): Observable<{ data: Photo[]; pages: number }> {
return this.http
.get<FlickrAPIResponse>('https://www.flickr.com/services/rest/', {
params: {
tags: searchTerm,
method: 'flickr.photos.search',
Expand All @@ -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 };
}),
);
}
}
Loading
Loading