feat(challenge30): interop rxjs signal

This commit is contained in:
thomas
2023-07-11 21:44:06 +02:00
parent 31366de7a6
commit ebede121e7
25 changed files with 96 additions and 88 deletions

View File

@@ -0,0 +1,36 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts"],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
]
},
"extends": [
"plugin:@nx/angular",
"plugin:@angular-eslint/template/process-inline-templates"
]
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}

View File

@@ -0,0 +1,26 @@
<h1>interoperability Rxjs and Signal</h1>
> Author: Thomas Laforge
### Information
In this challenge, we have a small reactive application using RxJS and NgRx/Component-Store.
The goal of this challenge is to use the new **Signal API** introduced in Angular v16. However, we should not convert everything. Certain portions of the code are better suited for RxJS rather than Signal. It is up to you to determine the threshold and observe how **Signal and RxJS coexist**, as well as how the interoperability is achieved in Angular.
### Submitting your work
1. Fork the project
2. clone it
3. npm ci
4. `npx nx serve interop-rxjs-signal`
5. _...work on it_
6. Commit your work
7. Submit a PR with a title beginning with **Answer:30** that I will review and other dev can review.
<a href="https://github.com/tomalaforge/angular-challenges/pulls?q=label%3A30+label%3Aanswer"><img src="https://img.shields.io/badge/-Solutions-green" alt="interop-rxjs-signal"/></a>
<a href='https://github.com/tomalaforge/angular-challenges/pulls?q=label%3A30+label%3A"answer+author"'><img src="https://img.shields.io/badge/-Author solution-important" alt="interop-rxjs-signal solution author"/></a>
<!-- <a href="{Blog post url}" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/-Blog post explanation-blue" alt="interop-rxjs-signal blog article"/></a> -->
_You can ask any question on_ <a href="https://twitter.com/laforge_toma" target="_blank" rel="noopener noreferrer"><img src="./../../logo/twitter.svg" height=20px alt="twitter"/></a>

View File

@@ -0,0 +1,22 @@
/* eslint-disable */
export default {
displayName: 'interop-rxjs-signal',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/apps/interop-rxjs-signal',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,98 @@
{
"name": "interop-rxjs-signal",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/interop-rxjs-signal/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:browser",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/interop-rxjs-signal",
"index": "apps/interop-rxjs-signal/src/index.html",
"main": "apps/interop-rxjs-signal/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/interop-rxjs-signal/tsconfig.app.json",
"assets": [
"apps/interop-rxjs-signal/src/favicon.ico",
"apps/interop-rxjs-signal/src/assets"
],
"styles": [
"apps/interop-rxjs-signal/src/styles.scss",
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "interop-rxjs-signal:build:production"
},
"development": {
"browserTarget": "interop-rxjs-signal:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "interop-rxjs-signal:build"
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"apps/interop-rxjs-signal/**/*.ts",
"apps/interop-rxjs-signal/**/*.html"
]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/interop-rxjs-signal/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
standalone: true,
imports: [RouterOutlet],
selector: 'app-root',
template: `<router-outlet />`,
styles: [''],
})
export class AppComponent {}

View File

@@ -0,0 +1,29 @@
import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideRouter, withComponentInputBinding } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideAnimations(),
provideRouter(
[
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./list/photos.component'),
},
{
path: 'detail',
loadComponent: () => import('./detail/detail.component'),
},
{
path: '**',
redirectTo: '',
},
],
withComponentInputBinding()
),
],
};

View File

@@ -0,0 +1,33 @@
import { DatePipe } from '@angular/common';
import { Component, Input as RouterInput } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Photo } from '../photo.model';
@Component({
selector: 'app-photos',
standalone: true,
imports: [DatePipe, RouterLink],
template: `
<img src="{{ photo.url_m }}" alt="{{ photo.title }}" class="image" />
<p><span class="font-bold">Title:</span> {{ photo.title }}</p>
<p><span class="font-bold">Owner:</span> {{ photo.ownername }}</p>
<p><span class="font-bold">Date:</span> {{ photo.datetaken | date }}</p>
<p><span class="font-bold">Tags:</span> {{ photo.tags }}</p>
<button
class="border border-black rounded-md px-4 py-2 mt-10"
routerLink="">
Back
</button>
`,
host: {
class: 'p-5 block',
},
})
export default class DetailComponent {
@RouterInput({
required: true,
transform: (value: string) => JSON.parse(decodeURIComponent(value)),
})
photo!: Photo;
}

View File

@@ -0,0 +1,119 @@
import { NgFor, NgIf } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
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 { Photo } from '../photo.model';
import { PhotoStore } from './photos.store';
@Component({
selector: 'app-photos',
standalone: true,
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatProgressBarModule,
NgIf,
NgFor,
MatInputModule,
LetDirective,
RouterLinkWithHref,
],
template: `
<h2 class="text-xl mb-2">Photos</h2>
<mat-form-field appearance="fill">
<mat-label>Search</mat-label>
<input
type="text"
matInput
[formControl]="search"
placeholder="write an article" />
</mat-form-field>
<ng-container *ngrxLet="vm$ as vm">
<section class="flex flex-col">
<section class="flex gap-3 items-center">
<button
[disabled]="vm.page === 1"
[class.bg-gray-400]="vm.page === 1"
class="text-xl border rounded-md p-3"
(click)="store.previousPage()">
<
</button>
<button
[disabled]="vm.endOfPage"
[class.bg-gray-400]="vm.endOfPage"
class="text-xl border rounded-md p-3"
(click)="store.nextPage()">
>
</button>
Page :{{ vm.page }} / {{ vm.pages }}
</section>
<mat-progress-bar
mode="query"
*ngIf="vm.loading"
class="mt-5"></mat-progress-bar>
<ul
class="flex flex-wrap gap-4"
*ngIf="vm.photos && vm.photos.length > 0; else noPhoto">
<li *ngFor="let photo of vm.photos; trackBy: trackById">
<a routerLink="detail" [queryParams]="{ photo: encode(photo) }">
<img
src="{{ photo.url_q }}"
alt="{{ photo.title }}"
class="image" />
</a>
</li>
</ul>
<ng-template #noPhoto>
<div>No Photos found. Type a search word.</div>
</ng-template>
<footer class="text-red-500">
{{ vm.error }}
</footer>
</section>
</ng-container>
`,
providers: [provideComponentStore(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;
}
})
);
private formInit = false;
search = new FormControl();
ngOnInit(): void {
this.store.search(
this.search.valueChanges.pipe(
skipWhile(() => !this.formInit),
debounceTime(300),
distinctUntilChanged()
)
);
}
trackById(index: number, photo: Photo) {
return photo.id;
}
encode(photo: Photo) {
return encodeURIComponent(JSON.stringify(photo));
}
}

View File

@@ -0,0 +1,135 @@
import { Injectable, inject } from '@angular/core';
import {
ComponentStore,
OnStateInit,
OnStoreInit,
tapResponse,
} from '@ngrx/component-store';
import { pipe } from 'rxjs';
import { filter, mergeMap, tap } from 'rxjs/operators';
import { Photo } from '../photo.model';
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$,
},
{ 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 })
)
)
)
)
);
}

View File

@@ -0,0 +1,10 @@
export interface Photo {
id: string;
title: string;
tags: string;
owner: string;
ownername: string;
datetaken: string;
url_q: string;
url_m: string;
}

View File

@@ -0,0 +1,39 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Photo } from './photo.model';
export interface FlickrAPIResponse {
photos: {
pages: number;
photo: Photo[];
};
}
@Injectable({ providedIn: 'root' })
export class PhotoService {
private http = inject(HttpClient);
public searchPublicPhotos(
searchTerm: string,
page: number
): Observable<FlickrAPIResponse> {
return this.http.get<FlickrAPIResponse>(
'https://www.flickr.com/services/rest/',
{
params: {
tags: searchTerm,
method: 'flickr.photos.search',
format: 'json',
nojsoncallback: '1',
tag_mode: 'all',
media: 'photos',
per_page: '30',
page,
extras: 'tags,date_taken,owner_name,url_q,url_m',
api_key: 'c3050d39a5bb308d9921bef0e15c437d',
},
}
);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>rxjs-to-signal</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);

View File

@@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* You can add global styles to this file, and also import other style files */

View File

@@ -0,0 +1,2 @@
import '@testing-library/jest-dom';
import 'jest-preset-angular/setup-jest';

View File

@@ -0,0 +1,14 @@
const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind');
const { join } = require('path');
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
...createGlobPatternsForDependencies(__dirname),
],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"],
"exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"compilerOptions": {
"types": ["jest", "node"]
}
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "es2022",
"useDefineForClassFields": false,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./tsconfig.editor.json"
}
],
"extends": "../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node", "@testing-library/jest-dom"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}