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

@@ -45,6 +45,7 @@ If you would like to propose a challenge, this project is open source, so feel f
<a href="./apps/di/README.md"><img src="https://img.shields.io/badge/16-di-red" alt="di"/></a> <a href="./apps/di/README.md"><img src="https://img.shields.io/badge/16-di-red" alt="di"/></a>
<a href="./apps/anchor-scrolling/README.md"><img src="https://img.shields.io/badge/21-anchor--scrolling-green" alt="anchor-scrolling"/></a> <a href="./apps/anchor-scrolling/README.md"><img src="https://img.shields.io/badge/21-anchor--scrolling-green" alt="anchor-scrolling"/></a>
<a href="./apps/router-input/README.md"><img src="https://img.shields.io/badge/22-router--input-green" alt="router-input"/></a> <a href="./apps/router-input/README.md"><img src="https://img.shields.io/badge/22-router--input-green" alt="router-input"/></a>
<a href="./apps/interop-rxjs-signal/README.md"><img src="https://img.shields.io/badge/22-interop rxjs signal-red" alt="interop signal rxjs"/></a>
</br> </br>
<img src="https://img.shields.io/badge/Typescript--gray?logo=typescript" alt="Typescript"/> <img src="https://img.shields.io/badge/Typescript--gray?logo=typescript" alt="Typescript"/>
@@ -92,7 +93,7 @@ If you would like to propose a challenge, this project is open source, so feel f
<table> <table>
<tbody> <tbody>
<tr> <tr>
<td align="center" valign="top" width="14.28%"><a href="https://medium.com/@thomas.laforge"><img src="https://avatars.githubusercontent.com/u/30832608?s…00&u=6f0ad9676792f29fd7fe6e113df06213d384a813&v=4" width="100px;" alt="Thomas Laforge"/><br /><sub><b>Thomas Laforge</b></sub></a><br />29 🧩</a></td> <td align="center" valign="top" width="14.28%"><a href="https://medium.com/@thomas.laforge"><img src="https://avatars.githubusercontent.com/u/30832608?s…00&u=6f0ad9676792f29fd7fe6e113df06213d384a813&v=4" width="100px;" alt="Thomas Laforge"/><br /><sub><b>Thomas Laforge</b></sub></a><br />30 🧩</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -24,7 +24,6 @@ export class AppComponent implements OnInit {
this.http this.http
.get<any[]>('https://jsonplaceholder.typicode.com/todos') .get<any[]>('https://jsonplaceholder.typicode.com/todos')
.subscribe((todos) => { .subscribe((todos) => {
console.log('return', todos);
this.todos = todos; this.todos = todos;
}); });
} }

View File

@@ -1,32 +1,26 @@
<h1>migrate application to signals</h1> <h1>interoperability Rxjs and Signal</h1>
> Author: Thomas Laforge > Author: Thomas Laforge
<!-- TODO: add Information/Statement/Rules/Constraint/Steps -->
### Information ### Information
### Statement In this challenge, we have a small reactive application using RxJS and NgRx/Component-Store.
### Step 1 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.
### Step 2
### Constraints:
### Submitting your work ### Submitting your work
1. Fork the project 1. Fork the project
2. clone it 2. clone it
3. npm ci 3. npm ci
4. `npx nx serve rxjs-to-signal` 4. `npx nx serve interop-rxjs-signal`
5. _...work on it_ 5. _...work on it_
6. Commit your work 6. Commit your work
7. Submit a PR with a title beginning with **Answer:30** that I will review and other dev can review. 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="rxjs-to-signal"/></a> <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="rxjs-to-signal solution author"/></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="rxjs-to-signal blog article"/></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> _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

@@ -1,9 +1,9 @@
/* eslint-disable */ /* eslint-disable */
export default { export default {
displayName: 'rxjs-to-signal', displayName: 'interop-rxjs-signal',
preset: '../../jest.preset.js', preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../coverage/apps/rxjs-to-signal', coverageDirectory: '../../coverage/apps/interop-rxjs-signal',
transform: { transform: {
'^.+\\.(ts|mjs|js|html)$': [ '^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular', 'jest-preset-angular',

View File

@@ -1,26 +1,26 @@
{ {
"name": "rxjs-to-signal", "name": "interop-rxjs-signal",
"$schema": "../../node_modules/nx/schemas/project-schema.json", "$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application", "projectType": "application",
"prefix": "app", "prefix": "app",
"sourceRoot": "apps/rxjs-to-signal/src", "sourceRoot": "apps/interop-rxjs-signal/src",
"tags": [], "tags": [],
"targets": { "targets": {
"build": { "build": {
"executor": "@angular-devkit/build-angular:browser", "executor": "@angular-devkit/build-angular:browser",
"outputs": ["{options.outputPath}"], "outputs": ["{options.outputPath}"],
"options": { "options": {
"outputPath": "dist/apps/rxjs-to-signal", "outputPath": "dist/apps/interop-rxjs-signal",
"index": "apps/rxjs-to-signal/src/index.html", "index": "apps/interop-rxjs-signal/src/index.html",
"main": "apps/rxjs-to-signal/src/main.ts", "main": "apps/interop-rxjs-signal/src/main.ts",
"polyfills": ["zone.js"], "polyfills": ["zone.js"],
"tsConfig": "apps/rxjs-to-signal/tsconfig.app.json", "tsConfig": "apps/interop-rxjs-signal/tsconfig.app.json",
"assets": [ "assets": [
"apps/rxjs-to-signal/src/favicon.ico", "apps/interop-rxjs-signal/src/favicon.ico",
"apps/rxjs-to-signal/src/assets" "apps/interop-rxjs-signal/src/assets"
], ],
"styles": [ "styles": [
"apps/rxjs-to-signal/src/styles.scss", "apps/interop-rxjs-signal/src/styles.scss",
"./node_modules/@angular/material/prebuilt-themes/indigo-pink.css" "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css"
], ],
"scripts": [] "scripts": []
@@ -56,10 +56,10 @@
"executor": "@angular-devkit/build-angular:dev-server", "executor": "@angular-devkit/build-angular:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"browserTarget": "rxjs-to-signal:build:production" "browserTarget": "interop-rxjs-signal:build:production"
}, },
"development": { "development": {
"browserTarget": "rxjs-to-signal:build:development" "browserTarget": "interop-rxjs-signal:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
@@ -67,7 +67,7 @@
"extract-i18n": { "extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n", "executor": "@angular-devkit/build-angular:extract-i18n",
"options": { "options": {
"browserTarget": "rxjs-to-signal:build" "browserTarget": "interop-rxjs-signal:build"
} }
}, },
"lint": { "lint": {
@@ -75,8 +75,8 @@
"outputs": ["{options.outputFile}"], "outputs": ["{options.outputFile}"],
"options": { "options": {
"lintFilePatterns": [ "lintFilePatterns": [
"apps/rxjs-to-signal/**/*.ts", "apps/interop-rxjs-signal/**/*.ts",
"apps/rxjs-to-signal/**/*.html" "apps/interop-rxjs-signal/**/*.html"
] ]
} }
}, },
@@ -84,7 +84,7 @@
"executor": "@nx/jest:jest", "executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"], "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": { "options": {
"jestConfig": "apps/rxjs-to-signal/jest.config.ts", "jestConfig": "apps/interop-rxjs-signal/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"configurations": { "configurations": {

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

@@ -7,7 +7,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterLinkWithHref } from '@angular/router'; import { RouterLinkWithHref } from '@angular/router';
import { LetDirective } from '@ngrx/component'; import { LetDirective } from '@ngrx/component';
import { provideComponentStore } from '@ngrx/component-store'; import { provideComponentStore } from '@ngrx/component-store';
import { debounceTime, distinctUntilChanged } from 'rxjs'; import { debounceTime, distinctUntilChanged, skipWhile, tap } from 'rxjs';
import { Photo } from '../photo.model'; import { Photo } from '../photo.model';
import { PhotoStore } from './photos.store'; import { PhotoStore } from './photos.store';
@@ -38,10 +38,6 @@ import { PhotoStore } from './photos.store';
<ng-container *ngrxLet="vm$ as vm"> <ng-container *ngrxLet="vm$ as vm">
<section class="flex flex-col"> <section class="flex flex-col">
<mat-progress-bar
mode="query"
*ngIf="vm.loading"
class="mt-5"></mat-progress-bar>
<section class="flex gap-3 items-center"> <section class="flex gap-3 items-center">
<button <button
[disabled]="vm.page === 1" [disabled]="vm.page === 1"
@@ -59,6 +55,10 @@ import { PhotoStore } from './photos.store';
</button> </button>
Page :{{ vm.page }} / {{ vm.pages }} Page :{{ vm.page }} / {{ vm.pages }}
</section> </section>
<mat-progress-bar
mode="query"
*ngIf="vm.loading"
class="mt-5"></mat-progress-bar>
<ul <ul
class="flex flex-wrap gap-4" class="flex flex-wrap gap-4"
*ngIf="vm.photos && vm.photos.length > 0; else noPhoto"> *ngIf="vm.photos && vm.photos.length > 0; else noPhoto">
@@ -87,13 +87,25 @@ import { PhotoStore } from './photos.store';
}) })
export default class PhotosComponent implements OnInit { export default class PhotosComponent implements OnInit {
store = inject(PhotoStore); store = inject(PhotoStore);
readonly vm$ = this.store.vm$; readonly vm$ = this.store.vm$.pipe(
tap(({ search }) => {
if (!this.formInit) {
this.search.setValue(search);
this.formInit = true;
}
})
);
private formInit = false;
search = new FormControl(); search = new FormControl();
ngOnInit(): void { ngOnInit(): void {
this.store.search( this.store.search(
this.search.valueChanges.pipe(debounceTime(300), distinctUntilChanged()) this.search.valueChanges.pipe(
skipWhile(() => !this.formInit),
debounceTime(300),
distinctUntilChanged()
)
); );
} }

View File

@@ -10,6 +10,8 @@ import { filter, mergeMap, tap } from 'rxjs/operators';
import { Photo } from '../photo.model'; import { Photo } from '../photo.model';
import { PhotoService } from '../photos.service'; import { PhotoService } from '../photos.service';
const PHOTO_STATE_KEY = 'photo_search';
export interface PhotoState { export interface PhotoState {
photos: Photo[]; photos: Photo[];
search: string; search: string;
@@ -51,6 +53,7 @@ export class PhotoStore
readonly vm$ = this.select( readonly vm$ = this.select(
{ {
photos: this.photos$, photos: this.photos$,
search: this.search$,
page: this.page$, page: this.page$,
pages: this.pages$, pages: this.pages$,
endOfPage: this.endOfPage$, endOfPage: this.endOfPage$,
@@ -61,7 +64,17 @@ export class PhotoStore
); );
ngrxOnStoreInit() { ngrxOnStoreInit() {
this.setState(initialState); 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() { ngrxOnStateInit() {
@@ -108,6 +121,10 @@ export class PhotoStore
photos: photo, photos: photo,
pages, pages,
}); });
localStorage.setItem(
PHOTO_STATE_KEY,
JSON.stringify({ search, page })
);
}, },
(error: unknown) => this.patchState({ error, loading: false }) (error: unknown) => this.patchState({ error, loading: false })
) )

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,48 +0,0 @@
import { JsonPipe, NgFor, NgIf } from '@angular/common';
import { Component, Input } from '@angular/core';
import { 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 { LetDirective } from '@ngrx/component';
import { Photo } from '../photo.model';
@Component({
selector: 'app-photos',
standalone: true,
imports: [
ReactiveFormsModule,
MatFormFieldModule,
MatProgressBarModule,
NgIf,
NgFor,
MatInputModule,
LetDirective,
JsonPipe,
],
template: `
<img src="{{ photo.url_q }}" alt="{{ photo.title }}" class="image" />
{{ photo | json }}
`,
// providers: [provideComponentStore(PhotoStore)],
host: {
class: 'p-5 block',
},
})
export default class DetailComponent {
@Input({ required: true }) photo!: Photo;
// store = inject(PhotoStore);
// readonly vm$ = this.store.vm$;
// search = new FormControl();
// ngOnInit(): void {
// this.store.search(
// this.search.valueChanges.pipe(debounceTime(300), distinctUntilChanged())
// );
// }
// trackById(index: number, photo: Photo) {
// return photo.id;
// }
}