From 09fb9a3d98edf27d4e418c7d7d23949acb3fcc45 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Wed, 11 Feb 2026 18:48:29 -0600 Subject: [PATCH] MME-13 Add delete menu action and user feedback for recipes. --- angular.json | 2 +- package-lock.json | 15 ++++---- package.json | 1 + src/app/app.config.ts | 3 +- .../recipe-page-content.css | 10 ++++++ .../recipe-page-content.html | 32 +++++++++++------ .../recipe-page-content.ts | 34 +++++++++++++++++-- .../ConfirmationDialogData.ts | 4 +++ .../confirmation-dialog.css | 8 +++++ .../confirmation-dialog.html | 7 ++++ .../confirmation-dialog.spec.ts | 22 ++++++++++++ .../confirmation-dialog.ts | 25 ++++++++++++++ src/app/shared/services/RecipeService.ts | 6 +++- src/style-imports.scss | 1 + 14 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 src/app/shared/components/confirmation-dialog/ConfirmationDialogData.ts create mode 100644 src/app/shared/components/confirmation-dialog/confirmation-dialog.css create mode 100644 src/app/shared/components/confirmation-dialog/confirmation-dialog.html create mode 100644 src/app/shared/components/confirmation-dialog/confirmation-dialog.spec.ts create mode 100644 src/app/shared/components/confirmation-dialog/confirmation-dialog.ts create mode 100644 src/style-imports.scss diff --git a/angular.json b/angular.json index 9672c0f..e94926b 100644 --- a/angular.json +++ b/angular.json @@ -25,7 +25,7 @@ "input": "public" } ], - "styles": ["src/material-theme.scss", "src/styles.css"] + "styles": ["src/material-theme.scss", "src/styles.css", "src/style-imports.scss"] }, "configurations": { "production": { diff --git a/package-lock.json b/package-lock.json index 1e61f7d..561887f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@tanstack/angular-query-experimental": "^5.90.16", - "ngx-sse-client": "^20.0.1", + "ngx-toastr": "^20.0.5", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, @@ -6773,17 +6773,18 @@ "node": ">= 0.6" } }, - "node_modules/ngx-sse-client": { - "version": "20.0.1", - "resolved": "https://registry.npmjs.org/ngx-sse-client/-/ngx-sse-client-20.0.1.tgz", - "integrity": "sha512-OSFRirL5beveGj4An3lOzWwg/JZWJG4Q1TdbyW7lqSDacfwINpIjSHdWlpiQwIghKU7BtLAc6TonUGlU4MzGTQ==", + "node_modules/ngx-toastr": { + "version": "20.0.5", + "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-20.0.5.tgz", + "integrity": "sha512-JcGu1Cbl+0SovPhxma72ygGeZHtpHWWKwBCyiabb+MSWYtXu/SOwEZ2HTWtZ4wcEYOOiy9tDQZgiEKWXpibpRw==", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "peerDependencies": { - "@angular/common": ">=20.0.0", - "@angular/core": ">=20.0.0" + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "rxjs": "^7.8.2" } }, "node_modules/node-addon-api": { diff --git a/package.json b/package.json index 79c0fbc..bf3cd8c 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@fortawesome/angular-fontawesome": "^4.0.0", "@fortawesome/free-solid-svg-icons": "^7.1.0", "@tanstack/angular-query-experimental": "^5.90.16", + "ngx-toastr": "^20.0.5", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/src/app/app.config.ts b/src/app/app.config.ts index b0671c6..8075116 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,11 +1,11 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; - import { routes } from './app.routes'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { authInterceptor } from './shared/interceptors/auth.interceptor'; import { provideTanStackQuery, QueryClient } from '@tanstack/angular-query-experimental'; import { withDevtools } from '@tanstack/angular-query-experimental/devtools'; +import { provideToastr } from 'ngx-toastr'; export const appConfig: ApplicationConfig = { providers: [ @@ -22,5 +22,6 @@ export const appConfig: ApplicationConfig = { }), withDevtools(), ), + provideToastr(), ], }; diff --git a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.css b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.css index 840d0a5..4e77b2b 100644 --- a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.css +++ b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.css @@ -26,3 +26,13 @@ article { width: 100%; object-fit: cover; } + +.recipe-actions { + display: flex; + column-gap: 10px; +} + +.actions-button { + padding: 0; + margin: 0; +} diff --git a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html index 71be4b8..07a1d5f 100644 --- a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html +++ b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.html @@ -3,20 +3,30 @@

{{ recipe.title }}

- @if (isLoggedIn()) { - + } @else { +
- Star {{ recipe.starCount }}
- - } @else { -
- - {{ recipe.starCount }} -
- } + } + @if (isOwner()) { + + + + + } +
diff --git a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts index 2f7ed2c..a97bdda 100644 --- a/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts +++ b/src/app/pages/recipe-page/recipe-page-content/recipe-page-content.ts @@ -2,7 +2,7 @@ import { Component, computed, inject, input } from '@angular/core'; import { RecipeView } from '../../../shared/models/Recipe.model'; import { CreateQueryOptions, injectMutation, injectQuery } from '@tanstack/angular-query-experimental'; import { ImageService } from '../../../shared/services/ImageService'; -import { faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; +import { faEllipsis, faGlobe, faLock, faStar, faUser } from '@fortawesome/free-solid-svg-icons'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { RecipeService } from '../../../shared/services/RecipeService'; import { AuthService } from '../../../shared/services/AuthService'; @@ -10,10 +10,16 @@ import { RecipeCommentsList } from '../../../shared/components/recipe-comments-l import { MatButton } from '@angular/material/button'; import { Spinner } from '../../../shared/components/spinner/spinner'; import { ImageViewWithBlobUrl } from '../../../shared/client-models/ImageViewWithBlobUrl'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { ConfirmationDialog } from '../../../shared/components/confirmation-dialog/confirmation-dialog'; +import { ConfirmationDialogData } from '../../../shared/components/confirmation-dialog/ConfirmationDialogData'; +import { ToastrService } from 'ngx-toastr'; @Component({ selector: 'app-recipe-page-content', - imports: [FaIconComponent, RecipeCommentsList, MatButton, Spinner], + imports: [FaIconComponent, RecipeCommentsList, MatButton, Spinner, MatMenuTrigger, MatMenu, MatMenuItem], templateUrl: './recipe-page-content.html', styleUrl: './recipe-page-content.css', }) @@ -23,8 +29,10 @@ export class RecipePageContent { private readonly imageService = inject(ImageService); private readonly recipeService = inject(RecipeService); private readonly authService = inject(AuthService); + private readonly router = inject(Router); protected readonly isLoggedIn = computed(() => !!this.authService.accessToken()); + protected readonly isOwner = computed(() => !!this.recipeView().isOwner); protected readonly mainImageQuery = injectQuery(() => { let options: Partial< @@ -48,8 +56,30 @@ export class RecipePageContent { mutationFn: () => this.recipeService.toggleStar(this.recipeView()), })); + private readonly dialog = inject(MatDialog); + private readonly toastrService = inject(ToastrService); + + protected onRecipeDelete(): void { + const dialogRef = this.dialog.open(ConfirmationDialog, { + data: { + title: 'Delete Recipe', + message: 'Are you sure you wish to delete this recipe?', + }, + }); + dialogRef.afterClosed().subscribe((confirmed) => { + if (confirmed) { + const recipe = this.recipeView().recipe; + this.recipeService.deleteRecipe(recipe.owner.username, recipe.slug).subscribe(async () => { + this.toastrService.success('Recipe successfully deleted'); + await this.router.navigate(['/']); + }); + } + }); + } + protected readonly faStar = faStar; protected readonly faUser = faUser; protected readonly faGlobe = faGlobe; protected readonly faLock = faLock; + protected readonly faEllipsis = faEllipsis; } diff --git a/src/app/shared/components/confirmation-dialog/ConfirmationDialogData.ts b/src/app/shared/components/confirmation-dialog/ConfirmationDialogData.ts new file mode 100644 index 0000000..916c852 --- /dev/null +++ b/src/app/shared/components/confirmation-dialog/ConfirmationDialogData.ts @@ -0,0 +1,4 @@ +export interface ConfirmationDialogData { + title: string; + message: string; +} diff --git a/src/app/shared/components/confirmation-dialog/confirmation-dialog.css b/src/app/shared/components/confirmation-dialog/confirmation-dialog.css new file mode 100644 index 0000000..4c51f07 --- /dev/null +++ b/src/app/shared/components/confirmation-dialog/confirmation-dialog.css @@ -0,0 +1,8 @@ +.confirmation-actions { + display: flex; + column-gap: 10px; +} + +button { + width: 100%; +} diff --git a/src/app/shared/components/confirmation-dialog/confirmation-dialog.html b/src/app/shared/components/confirmation-dialog/confirmation-dialog.html new file mode 100644 index 0000000..e6088df --- /dev/null +++ b/src/app/shared/components/confirmation-dialog/confirmation-dialog.html @@ -0,0 +1,7 @@ + +

{{ data.message }}

+
+ + +
+
diff --git a/src/app/shared/components/confirmation-dialog/confirmation-dialog.spec.ts b/src/app/shared/components/confirmation-dialog/confirmation-dialog.spec.ts new file mode 100644 index 0000000..a67a977 --- /dev/null +++ b/src/app/shared/components/confirmation-dialog/confirmation-dialog.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfirmationDialog } from './confirmation-dialog'; + +describe('ConfirmationDialog', () => { + let component: ConfirmationDialog; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ConfirmationDialog], + }).compileComponents(); + + fixture = TestBed.createComponent(ConfirmationDialog); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/confirmation-dialog/confirmation-dialog.ts b/src/app/shared/components/confirmation-dialog/confirmation-dialog.ts new file mode 100644 index 0000000..11758b3 --- /dev/null +++ b/src/app/shared/components/confirmation-dialog/confirmation-dialog.ts @@ -0,0 +1,25 @@ +import { Component, inject } from '@angular/core'; +import { DialogContainer } from '../dialog-container/dialog-container'; +import { MatButton } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { ConfirmationDialogData } from './ConfirmationDialogData'; + +@Component({ + selector: 'app-confirmation-dialog', + imports: [DialogContainer, MatButton], + templateUrl: './confirmation-dialog.html', + styleUrl: './confirmation-dialog.css', +}) +export class ConfirmationDialog { + protected readonly data: ConfirmationDialogData = inject(MAT_DIALOG_DATA); + + private readonly dialogRef: MatDialogRef = inject(MatDialogRef); + + protected onCancel(): void { + this.dialogRef.close(false); + } + + protected onOk(): void { + this.dialogRef.close(true); + } +} diff --git a/src/app/shared/services/RecipeService.ts b/src/app/shared/services/RecipeService.ts index 01c5fac..2b0ff32 100644 --- a/src/app/shared/services/RecipeService.ts +++ b/src/app/shared/services/RecipeService.ts @@ -1,6 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { firstValueFrom, lastValueFrom, map } from 'rxjs'; +import { firstValueFrom, lastValueFrom, map, Observable } from 'rxjs'; import { Recipe, RecipeInfoViews, RecipeView } from '../models/Recipe.model'; import { AuthService } from './AuthService'; import { QueryClient } from '@tanstack/angular-query-experimental'; @@ -78,4 +78,8 @@ export class RecipeService { ); return recipeInfoViews.results; } + + public deleteRecipe(username: string, slug: string): Observable { + return this.http.delete(this.endpointService.getUrl('recipes', [username, slug])); + } } diff --git a/src/style-imports.scss b/src/style-imports.scss new file mode 100644 index 0000000..81e7af2 --- /dev/null +++ b/src/style-imports.scss @@ -0,0 +1 @@ +@use "ngx-toastr/toastr";