From cfee7bf54396f2d54821b1296519dc2c12317fad Mon Sep 17 00:00:00 2001 From: namnguyen2k1 Date: Sat, 4 Oct 2025 11:05:29 +0700 Subject: [PATCH] feat: implement base-store, apply in post feature --- .vscode/settings.json | 3 + src/app/features/post/apis/post.api.ts | 39 +++++- .../post/components/post.component.ts | 16 ++- src/app/features/post/models/post.model.ts | 14 +-- .../post/pages/post-detail.component.ts | 2 +- .../post/pages/post-listing.component.ts | 43 ++++--- .../post/services/post-store.service.ts | 10 ++ .../features/post/services/post.service.ts | 112 +++++++++++++----- src/app/shared/services/base-store.service.ts | 91 ++++++++++++++ 9 files changed, 275 insertions(+), 55 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/app/features/post/services/post-store.service.ts create mode 100644 src/app/shared/services/base-store.service.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1729341 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.formatOnSave": true, +} \ No newline at end of file diff --git a/src/app/features/post/apis/post.api.ts b/src/app/features/post/apis/post.api.ts index 78dd21e..c9d655b 100644 --- a/src/app/features/post/apis/post.api.ts +++ b/src/app/features/post/apis/post.api.ts @@ -1,6 +1,7 @@ import { HttpClient } from "@angular/common/http"; -import { Injectable, inject } from "@angular/core"; +import { inject, Injectable } from "@angular/core"; import { API_SERVICE } from "@core/constants"; +import { map, Observable } from "rxjs"; import { Post } from "../models"; @Injectable({ providedIn: "root" }) @@ -9,10 +10,42 @@ export class PostApi { private readonly baseUrl = API_SERVICE.POST; getAll({ limit }: { limit: number }) { - return this.http.get(`${this.baseUrl}?_limit=${limit}`); + const url = `${this.baseUrl}?_limit=${limit}`; + + return this.http.get(url).pipe( + map((body) => { + return body.map((data) => { + return Post.create(data); + }); + }), + ); } getById(id: number) { - return this.http.get(`${this.baseUrl}/${id}`); + const url = `${this.baseUrl}/${id}`; + + return this.http.get(url).pipe( + map((body) => { + return Post.create(body); + }), + ); + } + + addPost(post: Omit): Observable { + const url = `${this.baseUrl}`; + + return this.http.post(url, post); + } + + updatePost(id: number, body: Partial): Observable { + const url = `${this.baseUrl}/${id}`; + + return this.http.put(url, body); + } + + deletePost(id: number): Observable { + const url = `${this.baseUrl}/${id}`; + + return this.http.delete(url); } } diff --git a/src/app/features/post/components/post.component.ts b/src/app/features/post/components/post.component.ts index 4df838d..49bdf13 100644 --- a/src/app/features/post/components/post.component.ts +++ b/src/app/features/post/components/post.component.ts @@ -1,7 +1,12 @@ -import { ChangeDetectionStrategy, Component, inject, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, inject, input, output } from "@angular/core"; import { Router } from "@angular/router"; import { Post } from "../models"; +export interface PostSubmitEvent { + type: "delete" | "update"; + data: Post; +} + @Component({ standalone: true, selector: "app-post", @@ -17,6 +22,7 @@ import { Post } from "../models"; + @@ -28,4 +34,12 @@ import { Post } from "../models"; export class PostComponent { router = inject(Router); data = input.required(); + submitEvent = output(); + + submitDelete() { + this.submitEvent.emit({ + type: "delete", + data: this.data(), + }); + } } diff --git a/src/app/features/post/models/post.model.ts b/src/app/features/post/models/post.model.ts index ec17cfe..3b14292 100644 --- a/src/app/features/post/models/post.model.ts +++ b/src/app/features/post/models/post.model.ts @@ -4,14 +4,14 @@ export class Post { title = ""; body = ""; - static create(input?: any) { - const m = new Post(); + static create(input?: Partial) { + const model = new Post(); - m.userId = input?.userId ?? m.userId; - m.id = input?.id ?? m.id; - m.title = input?.title ?? m.title; - m.body = input?.body ?? m.body; + model.userId = input?.userId ?? model.userId; + model.id = input?.id ?? model.id; + model.title = input?.title ?? model.title; + model.body = input?.body ?? model.body; - return m; + return model; } } diff --git a/src/app/features/post/pages/post-detail.component.ts b/src/app/features/post/pages/post-detail.component.ts index 9bafb47..8728b39 100644 --- a/src/app/features/post/pages/post-detail.component.ts +++ b/src/app/features/post/pages/post-detail.component.ts @@ -34,6 +34,6 @@ export class PostDetailComponent implements OnInit { post$?: Observable>; ngOnInit() { - this.post$ = this.postService.getPostById(this.id()).pipe(toFetchState()); + this.post$ = this.postService.getPostDetailById(this.id()).pipe(toFetchState()); } } diff --git a/src/app/features/post/pages/post-listing.component.ts b/src/app/features/post/pages/post-listing.component.ts index d1a3e70..0e4e25b 100644 --- a/src/app/features/post/pages/post-listing.component.ts +++ b/src/app/features/post/pages/post-listing.component.ts @@ -1,34 +1,47 @@ -import { AsyncPipe } from "@angular/common"; -import { ChangeDetectionStrategy, Component, inject } from "@angular/core"; -import { toFetchState } from "@core/utils"; +import { ChangeDetectionStrategy, Component, inject, OnInit, Signal } from "@angular/core"; +import { StateType } from "@shared/services/base-store.service"; import { PostComponent } from "../components"; +import { PostSubmitEvent } from "../components/post.component"; +import { Post } from "../models"; import { PostService } from "../services"; @Component({ selector: "app-post-list", - imports: [AsyncPipe, PostComponent], + imports: [PostComponent], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: `
Post Listing Page
- @let posts = posts$ | async; - @let data = posts?.data; - @if (data) { - @for (p of data; track $index) { - + @let state = postState(); + @if (state.error) { +
Loading posts failed....
+ } @else if (state.loading) { + + } @else { + @for (p of state.data; track $index) { + } @empty {
No post
} - } @else if (posts?.error) { -
Loading posts failed....
- } @else { - }
`, }) -export class PostListingComponent { +export class PostListingComponent implements OnInit { private readonly postService = inject(PostService); - protected posts$ = this.postService.getAllPosts().pipe(toFetchState()); + protected postState: Signal> = this.postService.postState; + + ngOnInit() { + this.postService.fetchAllPosts(); + } + + protected handlePostItemEvent(event: PostSubmitEvent) { + switch (event.type) { + case "delete": + return this.postService.deletePostById(event.data.id, false); + case "update": + return this.postService.updatePost(event.data.id, event.data, false); + } + } } diff --git a/src/app/features/post/services/post-store.service.ts b/src/app/features/post/services/post-store.service.ts new file mode 100644 index 0000000..ee7488f --- /dev/null +++ b/src/app/features/post/services/post-store.service.ts @@ -0,0 +1,10 @@ +import { Injectable } from "@angular/core"; +import { BaseStore } from "@shared/services/base-store.service"; +import { Post } from "../models"; + +@Injectable({ + providedIn: "root", +}) +export class PostStore extends BaseStore { + // override methods... +} diff --git a/src/app/features/post/services/post.service.ts b/src/app/features/post/services/post.service.ts index b1a14f6..d9c06c7 100644 --- a/src/app/features/post/services/post.service.ts +++ b/src/app/features/post/services/post.service.ts @@ -1,51 +1,107 @@ import { inject, Injectable } from "@angular/core"; -import { BehaviorSubject, map, of } from "rxjs"; +import { catchError, finalize, throwError } from "rxjs"; import { PostApi } from "../apis"; import { Post } from "../models"; +import { PostStore } from "./post-store.service"; @Injectable({ providedIn: "root", }) export class PostService { - private readonly PostApi = inject(PostApi); - private _posts$ = new BehaviorSubject<{ - data: Post[]; - expired: number; - }>({ data: [], expired: 0 }); - - get posts() { - return this._posts$.value.data; - } + private readonly postApi = inject(PostApi); + private readonly postStore = inject(PostStore); - clearCache() { - this._posts$.next({ data: [], expired: 0 }); + get postState() { + return this.postStore.state; } isExpired() { - const expired = Date.now() > this._posts$.value.expired; + const expired = Date.now() > this.postStore.state().expired; if (expired) { - this.clearCache(); + this.postStore.reset(); } return expired; } - getAllPosts() { - if (this.posts.length && !this.isExpired()) { - return of(this.posts); + fetchAllPosts() { + if (!this.isExpired()) { + console.log("data not expired"); + return; } - return this.PostApi.getAll({ limit: 100 }).pipe( - map((data) => { - this._posts$.next({ - data, - expired: Date.now() + 3 * 60 * 1000, // expires in 3 minutes - }); - return data; - }), - ); + this.postStore.setLoading(true); + this.postApi + .getAll({ limit: 20 }) + .pipe( + catchError((error) => { + console.log("fetch all post", error); + return throwError(() => error); + }), + finalize(() => { + this.postStore.setLoading(false); + }), + ) + .subscribe((data) => { + const expired = Date.now() + 1 * 60 * 1000; + this.postStore.setData(data, expired); + }); + } + + getPostDetailById(id: number) { + return this.postApi.getById(id); + } + + createPost(data: Post, loading = true) { + this.postStore.setLoading(loading); + this.postApi + .addPost(data) + .pipe( + catchError((error) => { + console.log("create-post", error); + return throwError(() => error); + }), + finalize(() => { + this.postStore.setLoading(false); + }), + ) + .subscribe((post) => { + this.postStore.addNewData(post); + }); + } + + updatePost(id: number, data: Partial, loading = true) { + this.postStore.setLoading(loading); + this.postApi + .updatePost(id, data) + .pipe( + catchError((error) => { + console.log("update-post", error); + return throwError(() => error); + }), + finalize(() => { + this.postStore.setLoading(false); + }), + ) + .subscribe((post) => { + this.postStore.updateDataById(id, post); + }); } - getPostById(id: number) { - return this.PostApi.getById(id); + deletePostById(id: number, loading = true) { + this.postStore.setLoading(loading); + this.postApi + .deletePost(id) + .pipe( + catchError((error) => { + console.log("delete-post", error); + return throwError(() => error); + }), + finalize(() => { + this.postStore.setLoading(false); + }), + ) + .subscribe(() => { + this.postStore.deleteDataById(id); + }); } } diff --git a/src/app/shared/services/base-store.service.ts b/src/app/shared/services/base-store.service.ts new file mode 100644 index 0000000..f501e9e --- /dev/null +++ b/src/app/shared/services/base-store.service.ts @@ -0,0 +1,91 @@ +import { Injectable, Signal, signal } from "@angular/core"; + +type IdType = string | number; + +export interface StateType { + data: T[]; + expired: number; + loading: boolean; + error: unknown; +} + +export function initialState(): StateType { + return { + data: [] as T[], + expired: 0, + loading: false, + error: null, + }; +} + +@Injectable() +export class BaseStore { + readonly #state = signal>(initialState()); + + get snapshot(): StateType { + return this.#state(); + } + + get state(): Signal> { + return this.#state.asReadonly(); + } + + setData(data: T[], expired: number = Date.now() + 3 * 60 * 1000): void { + // default expired in 3 minutes + this.#state.update(() => { + return { + data: [...data], + expired, + loading: false, + error: null, + }; + }); + } + + setLoading(loading: boolean) { + this.#state.update((prev) => { + return { ...prev, loading }; + }); + } + + setError(error: unknown) { + this.#state.update((prev) => { + return { ...prev, error }; + }); + } + + addNewData(item: T): void { + this.#state.update((prev) => ({ + ...prev, + data: [...prev.data, item], + })); + } + + getDataById(id: IdType): T | null { + return this.#state().data.find((item) => item.id === id) ?? null; + } + + updateDataById(id: IdType, updated: Partial): void { + this.#state.update((prev) => { + return { + ...prev, + data: prev.data.map((item) => { + return item.id === id ? { ...item, ...updated } : item; + }), + }; + }); + } + + deleteDataById(id: IdType): void { + this.#state.update((prev) => { + return { + ...prev, + data: prev.data.filter((item) => item.id !== id), + }; + }); + } + + reset(): void { + this.#state.set(initialState()); + } +}