MME-13 Add delete menu action and user feedback for recipes.

This commit is contained in:
Jesse Brault 2026-02-11 18:48:29 -06:00
parent 51bdbbbb6a
commit 09fb9a3d98
14 changed files with 147 additions and 23 deletions

View File

@ -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": {

15
package-lock.json generated
View File

@ -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": {

View File

@ -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"
},

View File

@ -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(),
],
};

View File

@ -26,3 +26,13 @@ article {
width: 100%;
object-fit: cover;
}
.recipe-actions {
display: flex;
column-gap: 10px;
}
.actions-button {
padding: 0;
margin: 0;
}

View File

@ -3,6 +3,7 @@
<div id="recipe-header">
<div>
<h1>{{ recipe.title }}</h1>
<div class="recipe-actions">
@if (isLoggedIn()) {
<button id="star" matButton="filled" (click)="starMutation.mutate()">
<div id="star-label">
@ -17,6 +18,15 @@
<span id="star-count">{{ recipe.starCount }}</span>
</div>
}
@if (isOwner()) {
<button class="actions-button" matButton="text" [matMenuTriggerFor]="recipeActionsMenu">
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon>
</button>
<mat-menu #recipeActionsMenu="matMenu">
<button mat-menu-item (click)="onRecipeDelete()">Delete recipe</button>
</mat-menu>
}
</div>
</div>
<div>
<div>

View File

@ -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, ConfirmationDialogData, boolean>(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;
}

View File

@ -0,0 +1,4 @@
export interface ConfirmationDialogData {
title: string;
message: string;
}

View File

@ -0,0 +1,8 @@
.confirmation-actions {
display: flex;
column-gap: 10px;
}
button {
width: 100%;
}

View File

@ -0,0 +1,7 @@
<app-dialog-container [title]="data.title">
<p>{{ data.message }}</p>
<div class="confirmation-actions">
<button matButton="outlined" (click)="onCancel()">Cancel</button>
<button matButton="filled" (click)="onOk()">Ok</button>
</div>
</app-dialog-container>

View File

@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfirmationDialog } from './confirmation-dialog';
describe('ConfirmationDialog', () => {
let component: ConfirmationDialog;
let fixture: ComponentFixture<ConfirmationDialog>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConfirmationDialog],
}).compileComponents();
fixture = TestBed.createComponent(ConfirmationDialog);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<ConfirmationDialogData, boolean> = inject(MatDialogRef);
protected onCancel(): void {
this.dialogRef.close(false);
}
protected onOk(): void {
this.dialogRef.close(true);
}
}

View File

@ -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<void> {
return this.http.delete<void>(this.endpointService.getUrl('recipes', [username, slug]));
}
}

1
src/style-imports.scss Normal file
View File

@ -0,0 +1 @@
@use "ngx-toastr/toastr";