Add infinite query to comments, some endpoint api stuff.

This commit is contained in:
Jesse Brault 2025-12-22 14:15:15 -06:00
parent 714fe60d9f
commit 0bc434251e
8 changed files with 123 additions and 29 deletions

3
src/app/endpoints.ts Normal file
View File

@ -0,0 +1,3 @@
export const Endpoints = {
recipes: 'recipes',
};

View File

@ -0,0 +1,11 @@
export interface QueryParams {
page?: number;
size?: number;
sort?: Array<string | Sort>;
}
export interface Sort {
property: string;
order?: 'ASC' | 'DESC';
ignoreCase?: boolean;
}

View File

@ -1,10 +1,8 @@
import { ResourceOwner } from './ResourceOwner.model'; import { ResourceOwner } from './ResourceOwner.model';
import { SliceView } from './SliceView.model';
export interface RecipeComments { export interface RecipeComments {
slice: { slice: SliceView;
number: number;
size: number;
};
content: RecipeComment[]; content: RecipeComment[];
} }

View File

@ -0,0 +1,5 @@
export interface SliceView {
hasNext: boolean;
number: number;
size: number;
}

View File

@ -14,29 +14,35 @@
<p>You must be logged in to comment.</p> <p>You must be logged in to comment.</p>
} }
<h3>Comments</h3> <h3>Comments</h3>
@if (commentsQuery.isLoading()) { @if (commentsQuery.isPending()) {
<p>Loading comments...</p> <p>Loading comments...</p>
} @else if (commentsQuery.isError()) { } @else if (commentsQuery.isError()) {
<p>There was an error loading the comments.</p> <p>There was an error loading the comments.</p>
} @else if (commentsQuery.isSuccess()) { } @else {
@let comments = commentsQuery.data(); <ul>
@if (comments.length) { @if (addCommentMutation.isPending()) {
<ul> <li style="opacity: 0.5">
@if (addCommentMutation.isPending()) { <p>{{ username() }}</p>
<li style="opacity: 0.5"> <div>{{ addCommentMutation.variables() }}</div>
<p>{{ username() }}</p> </li>
<div>{{ addCommentMutation.variables() }}</div> }
</li> @for (recipeComments of commentsQuery.data()?.pages; track $index) {
} @for (recipeComment of recipeComments.content; track recipeComment.id) {
@for (comment of comments; track $index) {
<li> <li>
<p>{{ comment.owner.username }} at {{ comment.created }}</p> <p>{{ recipeComment.owner.username }} at {{ recipeComment.created }}</p>
<div [innerHTML]="comment.text"></div> <div [innerHTML]="recipeComment.text"></div>
</li> </li>
} }
</ul> }
} @else { </ul>
<p>There are no comments yet.</p> <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>
}
</div>
} }
</div> </div>

View File

@ -1,8 +1,9 @@
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { RecipeService } from '../service/recipe.service'; import { RecipeService } from '../service/recipe.service';
import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; import { injectInfiniteQuery, injectMutation } from '@tanstack/angular-query-experimental';
import { AuthService } from '../service/auth.service'; import { AuthService } from '../service/auth.service';
import { RecipeComments } from '../model/RecipeComment.model';
@Component({ @Component({
selector: 'app-comments-list', selector: 'app-comments-list',
@ -20,9 +21,23 @@ 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 readonly commentsQuery = injectQuery(() => ({ protected commentsQuery = injectInfiniteQuery(() => ({
initialPageParam: 0,
getNextPageParam: (previousPage: RecipeComments) =>
previousPage.slice.hasNext ? previousPage.slice.number + 1 : undefined,
queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()], queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()],
queryFn: () => this.recipeService.getComments(this.recipeUsername(), this.recipeSlug()), queryFn: ({ pageParam }) => {
return this.recipeService.getComments(this.recipeUsername(), this.recipeSlug(), {
page: pageParam,
size: 1,
sort: [
{
property: 'created',
order: 'DESC',
},
],
});
},
})); }));
protected readonly addCommentForm = new FormGroup({ protected readonly addCommentForm = new FormGroup({
@ -32,6 +47,7 @@ export class RecipeCommentsList {
protected readonly addCommentMutation = injectMutation(() => ({ protected readonly addCommentMutation = injectMutation(() => ({
mutationFn: (commentText: string) => mutationFn: (commentText: string) =>
this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText), this.recipeService.addComment(this.recipeUsername(), this.recipeSlug(), commentText),
onSuccess: () => this.commentsQuery.fetchNextPage(),
})); }));
protected onCommentSubmit() { protected onCommentSubmit() {

View File

@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
import { Endpoints } from '../endpoints';
import { QueryParams } from '../model/Query.model';
@Injectable({
providedIn: 'root',
})
export class EndpointService {
public getUrl(
endpoint: keyof typeof Endpoints,
pathParams?: string[],
queryParams?: QueryParams,
): string {
const urlSearchParams = new URLSearchParams();
if (queryParams?.page !== undefined) {
urlSearchParams.set('page', queryParams.page.toString());
}
if (queryParams?.size !== undefined) {
urlSearchParams.set('size', queryParams.size.toString());
}
queryParams?.sort?.forEach((sort) => {
if (typeof sort === 'string') {
urlSearchParams.append('sort', sort);
} else {
let sortString = sort.property;
if (sort.order) {
sortString += ',' + sort.order;
}
if (sort.ignoreCase) {
sortString += ',IgnoreCase';
}
urlSearchParams.append('sort', sortString);
}
});
let pathString = pathParams?.join('/');
if (pathString?.length) {
pathString = '/' + pathString;
}
let queryString = urlSearchParams.toString();
if (queryString.length) {
queryString = '?' + queryString;
}
return `http://localhost:8080/${Endpoints[endpoint]}${pathString}${queryString}`;
}
}

View File

@ -5,6 +5,8 @@ import { Recipe, RecipeInfoViews, RecipeView } from '../model/Recipe.model';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { QueryClient } from '@tanstack/angular-query-experimental'; import { QueryClient } from '@tanstack/angular-query-experimental';
import { RecipeComment, RecipeComments } from '../model/RecipeComment.model'; import { RecipeComment, RecipeComments } from '../model/RecipeComment.model';
import { QueryParams } from '../model/Query.model';
import { EndpointService } from './endpoint.service';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -13,6 +15,7 @@ export class RecipeService {
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);
private readonly endpointService = inject(EndpointService);
public getRecipes(): Promise<Recipe[]> { public getRecipes(): Promise<Recipe[]> {
return firstValueFrom( return firstValueFrom(
@ -47,11 +50,15 @@ export class RecipeService {
} }
} }
public getComments(username: string, slug: string): Promise<RecipeComment[]> { public getComments(
username: string,
slug: string,
queryParams?: QueryParams,
): Promise<RecipeComments> {
return firstValueFrom( return firstValueFrom(
this.http this.http.get<RecipeComments>(
.get<RecipeComments>(`http://localhost:8080/recipes/${username}/${slug}/comments`) this.endpointService.getUrl('recipes', [username, slug, 'comments'], queryParams),
.pipe(map((res) => res.content)), ),
); );
} }