MME-34 Remove tanstack from recipe comments list.

This commit is contained in:
Jesse Brault 2026-02-20 18:16:21 -06:00
parent cd532ef092
commit b7c9e06d05
3 changed files with 172 additions and 70 deletions

View File

@ -11,41 +11,40 @@
} @else { } @else {
<p>You must be logged in to comment.</p> <p>You must be logged in to comment.</p>
} }
<h3>Comments</h3> <p>Showing {{ loadedCommentsCount() }} of {{ totalComments() }} comments.</p>
@if (commentsQuery.isPending()) { <ul id="comments">
<p>Loading comments...</p> @if (submittingComment()) {
} @else if (commentsQuery.isError()) { <li class="comment" style="opacity: 0.5">
<p>There was an error loading the comments.</p> <div class="comment-username-time">
} @else { <span class="comment-username">{{ username() }}</span>
<ul id="comments"> </div>
@if (addCommentMutation.isPending()) { <div>{{ submittedComment() }}</div>
<li class="comment" style="opacity: 0.5"> <app-spinner size="12px"></app-spinner>
</li>
}
@for (commentsSlice of commentsSlices(); track $index) {
@for (comment of commentsSlice.content; track $index) {
<li class="comment">
<div class="comment-username-time"> <div class="comment-username-time">
<span class="comment-username">{{ username() }}</span> <span class="comment-username">{{ comment.owner.username }}</span>
<span class="comment-time">{{ comment.created | dateTimeFormat }}</span>
</div> </div>
<div>{{ addCommentMutation.variables() }}</div> <div class="comment-text" [innerHTML]="comment.text"></div>
</li> </li>
} }
@for (recipeComments of commentsQuery.data()!.pages; track $index) { }
@for (recipeComment of recipeComments.content; track recipeComment.id) { @if (loadingComments()) {
<li class="comment"> <app-spinner></app-spinner>
<div class="comment-username-time"> }
<span class="comment-username">{{ recipeComment.owner.username }}</span> @if (loadCommentsError()) {
<span class="comment-time">{{ recipeComment.created | dateTimeFormat }}</span> <p>There was an error loading comments.</p>
</div> }
<div class="comment-text" [innerHTML]="recipeComment.text"></div> </ul>
</li> <div>
} @if (hasNextSlice()) {
} <button (click)="loadMoreComments()">Load more comments</button>
</ul> } @else {
<div> <p>No more comments.</p>
@if (commentsQuery.hasNextPage() && !commentsQuery.isFetchingNextPage()) { }
<button (click)="commentsQuery.fetchNextPage()">Load more comments</button> </div>
} @else if (commentsQuery.isFetchingNextPage()) {
<p>Loading comments...</p>
} @else if (!commentsQuery.hasNextPage()) {
<p>No additional comments to load.</p>
}
</div>
}
</div> </div>

View File

@ -1,19 +1,20 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input, OnInit, signal } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { RecipeService } from '../../services/RecipeService'; import { RecipeService } from '../../services/RecipeService';
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
import { AuthService } from '../../services/AuthService'; import { AuthService } from '../../services/AuthService';
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe'; import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe';
import { SliceView } from '../../models/SliceView.model'; import { SliceView } from '../../models/SliceView.model';
import { RecipeComment } from '../../models/RecipeComment.model'; import { RecipeComment } from '../../models/RecipeComment.model';
import { Spinner } from '../spinner/spinner';
import { range, switchMap, toArray } from 'rxjs';
@Component({ @Component({
selector: 'app-recipe-comments-list', selector: 'app-recipe-comments-list',
imports: [ReactiveFormsModule, DateTimeFormatPipe], imports: [ReactiveFormsModule, DateTimeFormatPipe, Spinner],
templateUrl: './recipe-comments-list.html', templateUrl: './recipe-comments-list.html',
styleUrl: './recipe-comments-list.css', styleUrl: './recipe-comments-list.css',
}) })
export class RecipeCommentsList { export class RecipeCommentsList implements OnInit {
public readonly recipeUsername = input.required<string>(); public readonly recipeUsername = input.required<string>();
public readonly recipeSlug = input.required<string>(); public readonly recipeSlug = input.required<string>();
@ -23,14 +24,32 @@ export class RecipeCommentsList {
protected readonly username = this.authService.username; protected readonly username = this.authService.username;
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
protected commentsQuery = injectInfiniteQuery(() => ({ protected readonly totalComments = signal<number>(0);
initialPageParam: 0,
getNextPageParam: (previousPage: SliceView<RecipeComment>) => protected readonly loadingComments = signal(false);
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined, protected readonly loadCommentsError = signal<Error | null>(null);
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()], protected readonly commentsSlices = signal<SliceView<RecipeComment>[]>([]);
queryFn: ({ pageParam }) => { protected readonly hasNextSlice = computed(() =>
return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), { this.commentsSlices().length ? this.commentsSlices()[this.commentsSlices().length - 1].slice.hasNext : null,
page: pageParam, );
protected readonly loadedCommentsCount = computed(() =>
this.commentsSlices().reduce((acc, slice) => acc + slice.content.length, 0),
);
protected readonly addCommentForm = new FormGroup({
comment: new FormControl('', Validators.required),
});
protected readonly submittingComment = signal(false);
protected readonly submitCommentError = signal<Error | null>(null);
protected readonly submittedComment = signal<string | null>('');
public ngOnInit(): void {
this.loadingComments.set(true);
this.loadCommentCount();
this.recipeService
.getComments(this.recipeUsername(), this.recipeSlug(), {
page: 0,
size: 10, size: 10,
sort: [ sort: [
{ {
@ -38,20 +57,100 @@ export class RecipeCommentsList {
order: 'DESC', order: 'DESC',
}, },
], ],
})
.subscribe({
next: (commentsSlice) => {
this.loadingComments.set(false);
this.commentsSlices.update((old) => [...old, commentsSlice]);
},
error: (e) => {
this.loadingComments.set(false);
this.loadCommentsError.set(e);
console.error(e);
},
}); });
}, }
}));
protected readonly addCommentForm = new FormGroup({ private loadCommentCount(): void {
comment: new FormControl('', Validators.required), this.recipeService.getCommentsCount(this.recipeUsername(), this.recipeSlug()).subscribe({
}); next: (count) => {
this.totalComments.set(count);
},
error: (e) => {
console.error(e);
},
});
}
protected readonly addCommentMutation = injectMutation(() => ({ protected loadMoreComments(): void {
mutationFn: (commentText: string) => if (this.hasNextSlice()) {
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText), this.loadingComments.set(true);
})); this.recipeService
.getComments(this.recipeUsername(), this.recipeSlug(), {
page: this.commentsSlices().length,
size: 10,
sort: [
{
property: 'created',
order: 'DESC',
},
],
})
.subscribe({
next: (nextSlice) => {
this.loadingComments.set(false);
this.commentsSlices.update((prev) => [...prev, nextSlice]);
},
error: (e) => {
this.loadingComments.set(false);
this.loadCommentsError.set(e);
console.error(e);
},
});
}
}
private reloadComments(): void {
this.loadCommentCount();
range(0, this.commentsSlices().length)
.pipe(
switchMap((page) => {
return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), {
page,
size: 10,
sort: [
{
property: 'created',
order: 'DESC',
},
],
});
}),
toArray(),
)
.subscribe({
next: (slices) => {
this.commentsSlices.set(slices);
},
error: (e) => {
console.error(e);
},
});
}
protected onCommentSubmit() { protected onCommentSubmit() {
this.addCommentMutation.mutate(this.addCommentForm.value.comment!); const comment = this.addCommentForm.value.comment!;
this.submittingComment.set(true);
this.submittedComment.set(comment);
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), comment).subscribe({
next: () => {
this.submittingComment.set(false);
this.reloadComments();
},
error: (e) => {
this.submitCommentError.set(e);
console.error(e);
},
});
} }
} }

View File

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { firstValueFrom, lastValueFrom, map, Observable } from 'rxjs'; import { lastValueFrom, map, Observable } from 'rxjs';
import { FullRecipeView, FullRecipeViewWrapper, RecipeInfoView } from '../models/Recipe.model'; import { FullRecipeView, FullRecipeViewWrapper, RecipeInfoView } from '../models/Recipe.model';
import { AuthService } from './AuthService'; import { AuthService } from './AuthService';
import { QueryClient } from '@tanstack/angular-query-experimental'; import { QueryClient } from '@tanstack/angular-query-experimental';
@ -27,6 +27,8 @@ export class RecipeService {
'totalTime', 'totalTime',
] as const; ] as const;
public static readonly RecipeCommentProperties = ['id', 'created', 'modified'] as const;
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly queryClient = inject(QueryClient); private readonly queryClient = inject(QueryClient);
@ -123,24 +125,26 @@ export class RecipeService {
} }
} }
public getComments(username: string, slug: string, queryParams?: QueryParams): Promise<SliceView<RecipeComment>> { public getComments(
return firstValueFrom( username: string,
this.http.get<SliceView<RecipeComment>>( slug: string,
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams), queryParams?: QueryParams<typeof RecipeService.RecipeCommentProperties>,
), ): Observable<SliceView<RecipeComment>> {
return this.http.get<SliceView<RecipeComment>>(
this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
); );
} }
public async addComment(username: string, slug: string, commentText: string): Promise<RecipeComment> { public getCommentsCount(username: string, slug: string): Observable<number> {
const comment = await firstValueFrom( return this.http
this.http.post<RecipeComment>(this.endpointService.getUrl('recipes', [username, slug, 'comments']), { .get<{ count: number }>(this.endpointService.getUrl('recipes', [username, slug, 'comments', 'count']))
text: commentText, .pipe(map((res) => res.count));
}), }
);
await this.queryClient.invalidateQueries({ public addComment(username: string, slug: string, commentText: string): Observable<RecipeComment> {
queryKey: ['recipeComments', username, slug], return this.http.post<RecipeComment>(this.endpointService.getUrl('recipes', [username, slug, 'comments']), {
text: commentText,
}); });
return comment;
} }
public aiSearch(prompt: string): Observable<FullRecipeView[]> { public aiSearch(prompt: string): Observable<FullRecipeView[]> {