From 714fe60d9fb2aa5a1e5f67f213200c7b38e90e57 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Sun, 21 Dec 2025 17:20:05 -0600 Subject: [PATCH] Add basic commenting to recipes. --- src/app/model/RecipeComment.model.ts | 19 +++++++++ .../recipe-comments-list.component.css | 0 .../recipe-comments-list.component.html | 42 +++++++++++++++++++ .../recipe-comments-list.component.spec.ts | 22 ++++++++++ .../recipe-comments-list.component.ts | 40 ++++++++++++++++++ .../recipe-page-content.html | 1 + .../recipe-page-content.ts | 3 +- src/app/service/recipe.service.ts | 32 +++++++++++++- 8 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/app/model/RecipeComment.model.ts create mode 100644 src/app/recipe-comments-list/recipe-comments-list.component.css create mode 100644 src/app/recipe-comments-list/recipe-comments-list.component.html create mode 100644 src/app/recipe-comments-list/recipe-comments-list.component.spec.ts create mode 100644 src/app/recipe-comments-list/recipe-comments-list.component.ts diff --git a/src/app/model/RecipeComment.model.ts b/src/app/model/RecipeComment.model.ts new file mode 100644 index 0000000..90ed00d --- /dev/null +++ b/src/app/model/RecipeComment.model.ts @@ -0,0 +1,19 @@ +import { ResourceOwner } from './ResourceOwner.model'; + +export interface RecipeComments { + slice: { + number: number; + size: number; + }; + content: RecipeComment[]; +} + +export interface RecipeComment { + id: number; + created: string; + modified: string | null; + text: string; + rawText: string | null; + owner: ResourceOwner; + recipeId: number; +} diff --git a/src/app/recipe-comments-list/recipe-comments-list.component.css b/src/app/recipe-comments-list/recipe-comments-list.component.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/recipe-comments-list/recipe-comments-list.component.html b/src/app/recipe-comments-list/recipe-comments-list.component.html new file mode 100644 index 0000000..601bfc3 --- /dev/null +++ b/src/app/recipe-comments-list/recipe-comments-list.component.html @@ -0,0 +1,42 @@ +
+

Comments

+ @if (isLoggedIn()) { +
+

Add Comment

+
+ + + + +
+
+ } @else { +

You must be logged in to comment.

+ } +

Comments

+ @if (commentsQuery.isLoading()) { +

Loading comments...

+ } @else if (commentsQuery.isError()) { +

There was an error loading the comments.

+ } @else if (commentsQuery.isSuccess()) { + @let comments = commentsQuery.data(); + @if (comments.length) { + + } @else { +

There are no comments yet.

+ } + } +
diff --git a/src/app/recipe-comments-list/recipe-comments-list.component.spec.ts b/src/app/recipe-comments-list/recipe-comments-list.component.spec.ts new file mode 100644 index 0000000..aa27e51 --- /dev/null +++ b/src/app/recipe-comments-list/recipe-comments-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecipeCommentsList } from './recipe-comments-list.component'; + +describe('CommentsList', () => { + let component: RecipeCommentsList; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecipeCommentsList], + }).compileComponents(); + + fixture = TestBed.createComponent(RecipeCommentsList); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/recipe-comments-list/recipe-comments-list.component.ts b/src/app/recipe-comments-list/recipe-comments-list.component.ts new file mode 100644 index 0000000..8d58a8f --- /dev/null +++ b/src/app/recipe-comments-list/recipe-comments-list.component.ts @@ -0,0 +1,40 @@ +import { Component, computed, inject, input } from '@angular/core'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { RecipeService } from '../service/recipe.service'; +import { injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; +import { AuthService } from '../service/auth.service'; + +@Component({ + selector: 'app-comments-list', + imports: [ReactiveFormsModule], + templateUrl: './recipe-comments-list.component.html', + styleUrl: './recipe-comments-list.component.css', +}) +export class RecipeCommentsList { + public readonly recipeUsername = input.required(); + public readonly recipeSlug = input.required(); + + private readonly recipeService = inject(RecipeService); + private readonly authService = inject(AuthService); + + protected readonly username = this.authService.username; + protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); + + protected readonly commentsQuery = injectQuery(() => ({ + queryKey: ['recipeComments', this.recipeUsername(), this.recipeSlug()], + queryFn: () => this.recipeService.getComments(this.recipeUsername(), this.recipeSlug()), + })); + + 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!); + } +} diff --git a/src/app/recipe-page/recipe-page-content/recipe-page-content.html b/src/app/recipe-page/recipe-page-content/recipe-page-content.html index eb60cbd..7abb564 100644 --- a/src/app/recipe-page/recipe-page-content/recipe-page-content.html +++ b/src/app/recipe-page/recipe-page-content/recipe-page-content.html @@ -38,4 +38,5 @@ /> }
+ diff --git a/src/app/recipe-page/recipe-page-content/recipe-page-content.ts b/src/app/recipe-page/recipe-page-content/recipe-page-content.ts index 8740835..75b5373 100644 --- a/src/app/recipe-page/recipe-page-content/recipe-page-content.ts +++ b/src/app/recipe-page/recipe-page-content/recipe-page-content.ts @@ -6,10 +6,11 @@ import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-ico import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecipeService } from '../../service/recipe.service'; import { AuthService } from '../../service/auth.service'; +import { RecipeCommentsList } from '../../recipe-comments-list/recipe-comments-list.component'; @Component({ selector: 'app-recipe-page-content', - imports: [FaIconComponent], + imports: [FaIconComponent, RecipeCommentsList], templateUrl: './recipe-page-content.html', styleUrl: './recipe-page-content.css', }) diff --git a/src/app/service/recipe.service.ts b/src/app/service/recipe.service.ts index f0fd110..01d1443 100644 --- a/src/app/service/recipe.service.ts +++ b/src/app/service/recipe.service.ts @@ -4,6 +4,7 @@ import { firstValueFrom, lastValueFrom, map } from 'rxjs'; import { Recipe, RecipeInfoViews, RecipeView } from '../model/Recipe.model'; import { AuthService } from './auth.service'; import { QueryClient } from '@tanstack/angular-query-experimental'; +import { RecipeComment, RecipeComments } from '../model/RecipeComment.model'; @Injectable({ providedIn: 'root', @@ -21,7 +22,7 @@ export class RecipeService { ); } - public async getRecipeView(username: string, slug: string): Promise { + public getRecipeView(username: string, slug: string): Promise { return firstValueFrom( this.http.get(`http://localhost:8080/recipes/${username}/${slug}`), ); @@ -31,7 +32,7 @@ export class RecipeService { return `http://localhost:8080/recipes/${recipeView.recipe.owner.username}/${recipeView.recipe.slug}`; } - public async toggleStar(recipeView: RecipeView) { + public async toggleStar(recipeView: RecipeView): Promise { if (this.authService.accessToken()) { if (recipeView.isStarred) { await lastValueFrom(this.http.delete(this.getRecipeUrl(recipeView) + '/star')); @@ -45,4 +46,31 @@ export class RecipeService { throw new Error('Cannot star a recipe when not logged in.'); } } + + public getComments(username: string, slug: string): Promise { + return firstValueFrom( + this.http + .get(`http://localhost:8080/recipes/${username}/${slug}/comments`) + .pipe(map((res) => res.content)), + ); + } + + public async addComment( + username: string, + slug: string, + commentText: string, + ): Promise { + const comment = await firstValueFrom( + this.http.post( + `http://localhost:8080/recipes/${username}/${slug}/comments`, + { + text: commentText, + }, + ), + ); + await this.queryClient.invalidateQueries({ + queryKey: ['recipeComments', username, slug], + }); + return comment; + } }