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 {
<p>You must be logged in to comment.</p>
}
<h3>Comments</h3>
@if (commentsQuery.isPending()) {
<p>Loading comments...</p>
} @else if (commentsQuery.isError()) {
<p>There was an error loading the comments.</p>
} @else {
<p>Showing {{ loadedCommentsCount() }} of {{ totalComments() }} comments.</p>
<ul id="comments">
@if (addCommentMutation.isPending()) {
@if (submittingComment()) {
<li class="comment" style="opacity: 0.5">
<div class="comment-username-time">
<span class="comment-username">{{ username() }}</span>
</div>
<div>{{ addCommentMutation.variables() }}</div>
<div>{{ submittedComment() }}</div>
<app-spinner size="12px"></app-spinner>
</li>
}
@for (recipeComments of commentsQuery.data()!.pages; track $index) {
@for (recipeComment of recipeComments.content; track recipeComment.id) {
@for (commentsSlice of commentsSlices(); track $index) {
@for (comment of commentsSlice.content; track $index) {
<li class="comment">
<div class="comment-username-time">
<span class="comment-username">{{ recipeComment.owner.username }}</span>
<span class="comment-time">{{ recipeComment.created | dateTimeFormat }}</span>
<span class="comment-username">{{ comment.owner.username }}</span>
<span class="comment-time">{{ comment.created | dateTimeFormat }}</span>
</div>
<div class="comment-text" [innerHTML]="recipeComment.text"></div>
<div class="comment-text" [innerHTML]="comment.text"></div>
</li>
}
}
@if (loadingComments()) {
<app-spinner></app-spinner>
}
@if (loadCommentsError()) {
<p>There was an error loading comments.</p>
}
</ul>
<div>
@if (commentsQuery.hasNextPage() && !commentsQuery.isFetchingNextPage()) {
<button (click)="commentsQuery.fetchNextPage()">Load more comments</button>
} @else if (commentsQuery.isFetchingNextPage()) {
<p>Loading comments...</p>
} @else if (!commentsQuery.hasNextPage()) {
<p>No additional comments to load.</p>
@if (hasNextSlice()) {
<button (click)="loadMoreComments()">Load more comments</button>
} @else {
<p>No more comments.</p>
}
</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 { RecipeService } from '../../services/RecipeService';
import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
import { AuthService } from '../../services/AuthService';
import { DateTimeFormatPipe } from '../../pipes/dateTimeFormat.pipe';
import { SliceView } from '../../models/SliceView.model';
import { RecipeComment } from '../../models/RecipeComment.model';
import { Spinner } from '../spinner/spinner';
import { range, switchMap, toArray } from 'rxjs';
@Component({
selector: 'app-recipe-comments-list',
imports: [ReactiveFormsModule, DateTimeFormatPipe],
imports: [ReactiveFormsModule, DateTimeFormatPipe, Spinner],
templateUrl: './recipe-comments-list.html',
styleUrl: './recipe-comments-list.css',
})
export class RecipeCommentsList {
export class RecipeCommentsList implements OnInit {
public readonly recipeUsername = input.required<string>();
public readonly recipeSlug = input.required<string>();
@ -23,14 +24,99 @@ export class RecipeCommentsList {
protected readonly username = this.authService.username;
protected readonly isLoggedIn = computed(() => !!this.authService.accessToken());
protected commentsQuery = injectInfiniteQuery(() => ({
initialPageParam: 0,
getNextPageParam: (previousPage: SliceView<RecipeComment>) =>
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
queryFn: ({ pageParam }) => {
protected readonly totalComments = signal<number>(0);
protected readonly loadingComments = signal(false);
protected readonly loadCommentsError = signal<Error | null>(null);
protected readonly commentsSlices = signal<SliceView<RecipeComment>[]>([]);
protected readonly hasNextSlice = computed(() =>
this.commentsSlices().length ? this.commentsSlices()[this.commentsSlices().length - 1].slice.hasNext : null,
);
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,
sort: [
{
property: 'created',
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);
},
});
}
private loadCommentCount(): void {
this.recipeService.getCommentsCount(this.recipeUsername(), this.recipeSlug()).subscribe({
next: (count) => {
this.totalComments.set(count);
},
error: (e) => {
console.error(e);
},
});
}
protected loadMoreComments(): void {
if (this.hasNextSlice()) {
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: pageParam,
page,
size: 10,
sort: [
{
@ -39,19 +125,32 @@ export class RecipeCommentsList {
},
],
});
}),
toArray(),
)
.subscribe({
next: (slices) => {
this.commentsSlices.set(slices);
},
error: (e) => {
console.error(e);
},
}));
protected readonly addCommentForm = new FormGroup({
comment: new FormControl('', Validators.required),
});
protected readonly addCommentMutation = injectMutation(() => ({
mutationFn: (commentText: string) =>
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText),
}));
}
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 { 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 { AuthService } from './AuthService';
import { QueryClient } from '@tanstack/angular-query-experimental';
@ -27,6 +27,8 @@ export class RecipeService {
'totalTime',
] as const;
public static readonly RecipeCommentProperties = ['id', 'created', 'modified'] as const;
private readonly http = inject(HttpClient);
private readonly authService = inject(AuthService);
private readonly queryClient = inject(QueryClient);
@ -123,24 +125,26 @@ export class RecipeService {
}
}
public getComments(username: string, slug: string, queryParams?: QueryParams): Promise<SliceView<RecipeComment>> {
return firstValueFrom(
this.http.get<SliceView<RecipeComment>>(
public getComments(
username: string,
slug: string,
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> {
const comment = await firstValueFrom(
this.http.post<RecipeComment>(this.endpointService.getUrl('recipes', [username, slug, 'comments']), {
public getCommentsCount(username: string, slug: string): Observable<number> {
return this.http
.get<{ count: number }>(this.endpointService.getUrl('recipes', [username, slug, 'comments', 'count']))
.pipe(map((res) => res.count));
}
public addComment(username: string, slug: string, commentText: string): Observable<RecipeComment> {
return this.http.post<RecipeComment>(this.endpointService.getUrl('recipes', [username, slug, 'comments']), {
text: commentText,
}),
);
await this.queryClient.invalidateQueries({
queryKey: ['recipeComments', username, slug],
});
return comment;
}
public aiSearch(prompt: string): Observable<FullRecipeView[]> {