From e071b0ed8c3c36ef79e80ee2527291758ee3f4e5 Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Sun, 15 Feb 2026 13:59:38 -0600 Subject: [PATCH] MME-14 Basic recipe edit page. --- src/app/app.routes.ts | 5 ++ .../recipe-edit-page/recipe-edit-page.css | 0 .../recipe-edit-page/recipe-edit-page.html | 22 ++++++ .../recipe-edit-page/recipe-edit-page.spec.ts | 22 ++++++ .../recipe-edit-page/recipe-edit-page.ts | 73 +++++++++++++++++++ .../recipe-page-content.html | 1 + .../recipe-page-content.ts | 5 ++ src/app/shared/bodies/RecipeUpdateBody.ts | 21 ++++++ .../recipe-edit-form/recipe-edit-form.html | 2 +- .../recipe-edit-form/recipe-edit-form.ts | 7 ++ src/app/shared/services/RecipeService.ts | 42 +++++++++++ 11 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/app/pages/recipe-edit-page/recipe-edit-page.css create mode 100644 src/app/pages/recipe-edit-page/recipe-edit-page.html create mode 100644 src/app/pages/recipe-edit-page/recipe-edit-page.spec.ts create mode 100644 src/app/pages/recipe-edit-page/recipe-edit-page.ts create mode 100644 src/app/shared/bodies/RecipeUpdateBody.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 9579269..c52a99c 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -4,6 +4,7 @@ import { RecipesPage } from './pages/recipes-page/recipes-page'; import { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page'; import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page'; import { authGuard } from './shared/guards/auth-guard'; +import { RecipeEditPage } from './pages/recipe-edit-page/recipe-edit-page'; export const routes: Routes = [ { @@ -23,4 +24,8 @@ export const routes: Routes = [ path: 'recipes/:username/:slug', component: RecipePage, }, + { + path: 'recipes/:username/:slug/edit', + component: RecipeEditPage, + }, ]; diff --git a/src/app/pages/recipe-edit-page/recipe-edit-page.css b/src/app/pages/recipe-edit-page/recipe-edit-page.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/pages/recipe-edit-page/recipe-edit-page.html b/src/app/pages/recipe-edit-page/recipe-edit-page.html new file mode 100644 index 0000000..f778f37 --- /dev/null +++ b/src/app/pages/recipe-edit-page/recipe-edit-page.html @@ -0,0 +1,22 @@ +

Edit Recipe

+@if (loadingRecipe()) { + +} @else if (loadRecipeError()) { +

There was an error loading the recipe: {{ loadRecipeError() }}

+} @else { + @let recipe = recipeView()!.recipe; +
+

Created: {{ recipe.created | date: "short" }}

+ @if (recipe.modified) { +

Last modified: {{ recipe.modified | date: "short" }}

+ } +
+ + @if (submittingRecipe()) { + + } +} diff --git a/src/app/pages/recipe-edit-page/recipe-edit-page.spec.ts b/src/app/pages/recipe-edit-page/recipe-edit-page.spec.ts new file mode 100644 index 0000000..baa8682 --- /dev/null +++ b/src/app/pages/recipe-edit-page/recipe-edit-page.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { RecipeEditPage } from './recipe-edit-page'; + +describe('RecipeEditPage', () => { + let component: RecipeEditPage; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RecipeEditPage], + }).compileComponents(); + + fixture = TestBed.createComponent(RecipeEditPage); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/recipe-edit-page/recipe-edit-page.ts b/src/app/pages/recipe-edit-page/recipe-edit-page.ts new file mode 100644 index 0000000..de93a7b --- /dev/null +++ b/src/app/pages/recipe-edit-page/recipe-edit-page.ts @@ -0,0 +1,73 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { FullRecipeViewWrapper } from '../../shared/models/Recipe.model'; +import { RecipeService } from '../../shared/services/RecipeService'; +import { Spinner } from '../../shared/components/spinner/spinner'; +import { DatePipe } from '@angular/common'; +import { RecipeEditForm } from '../../shared/components/recipe-edit-form/recipe-edit-form'; +import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent'; +import { ToastrService } from 'ngx-toastr'; + +@Component({ + selector: 'app-recipe-edit-page', + imports: [Spinner, DatePipe, RecipeEditForm], + templateUrl: './recipe-edit-page.html', + styleUrl: './recipe-edit-page.css', +}) +export class RecipeEditPage implements OnInit { + private readonly activatedRoute = inject(ActivatedRoute); + private readonly recipeService = inject(RecipeService); + private readonly router = inject(Router); + private readonly toastrService = inject(ToastrService); + + protected readonly loadingRecipe = signal(false); + protected readonly loadRecipeError = signal(null); + protected readonly recipeView = signal(null); + + protected readonly submittingRecipe = signal(false); + + public ngOnInit(): void { + this.activatedRoute.paramMap.subscribe((paramMap) => { + const username = paramMap.get('username')!; + const slug = paramMap.get('slug')!; + this.loadingRecipe.set(true); + this.recipeService.getRecipeView2(username, slug, true).subscribe({ + next: (recipeView) => { + this.loadingRecipe.set(false); + this.recipeView.set(recipeView); + }, + error: (e) => { + this.loadingRecipe.set(false); + this.loadRecipeError.set(e); + }, + }); + }); + } + + public onRecipeSubmit(event: RecipeEditFormSubmitEvent): void { + this.submittingRecipe.set(true); + const baseRecipe = this.recipeView()!.recipe; + this.recipeService + .updateRecipe(baseRecipe.owner.username, baseRecipe.slug, { + ...event, + mainImage: event.mainImage + ? { + username: event.mainImage.owner.username, + filename: event.mainImage.filename, + } + : undefined, + }) + .subscribe({ + next: async () => { + this.submittingRecipe.set(false); + await this.router.navigate(['recipes', baseRecipe.owner.username, baseRecipe.slug]); + this.toastrService.success('Recipe updated'); + }, + error: (e) => { + this.submittingRecipe.set(false); + console.error(e); + this.toastrService.error('Error submitting recipe'); + }, + }); + } +} 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 26dd8c9..05b7f64 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 @@ -23,6 +23,7 @@ + } 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 bf7084b..7d4773a 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 @@ -59,6 +59,11 @@ export class RecipePageContent { private readonly dialog = inject(MatDialog); private readonly toastrService = inject(ToastrService); + protected async onRecipeEdit(): Promise { + const recipe = this.recipeView().recipe; + await this.router.navigate(['recipes', recipe.owner.username, recipe.slug, 'edit']); + } + protected onRecipeDelete(): void { const dialogRef = this.dialog.open(ConfirmationDialog, { data: { diff --git a/src/app/shared/bodies/RecipeUpdateBody.ts b/src/app/shared/bodies/RecipeUpdateBody.ts new file mode 100644 index 0000000..f9576df --- /dev/null +++ b/src/app/shared/bodies/RecipeUpdateBody.ts @@ -0,0 +1,21 @@ +export interface RecipeUpdateBody { + title?: string | null; + preparationTime?: number | null; + cookingTime?: number | null; + totalTime?: number | null; + ingredients?: IngredientUpdateBody[] | null; + rawText?: string | null; + isPublic?: boolean | null; + mainImage?: MainImageUpdateBody | null; +} + +export interface IngredientUpdateBody { + amount?: string | null; + name: string; + notes?: string | null; +} + +export interface MainImageUpdateBody { + username: string; + filename: string; +} diff --git a/src/app/shared/components/recipe-edit-form/recipe-edit-form.html b/src/app/shared/components/recipe-edit-form/recipe-edit-form.html index 5c21b09..da207be 100644 --- a/src/app/shared/components/recipe-edit-form/recipe-edit-form.html +++ b/src/app/shared/components/recipe-edit-form/recipe-edit-form.html @@ -123,5 +123,5 @@ (input)="onRecipeTextChange($event)" > - + diff --git a/src/app/shared/components/recipe-edit-form/recipe-edit-form.ts b/src/app/shared/components/recipe-edit-form/recipe-edit-form.ts index 95dbef8..4a3ff86 100644 --- a/src/app/shared/components/recipe-edit-form/recipe-edit-form.ts +++ b/src/app/shared/components/recipe-edit-form/recipe-edit-form.ts @@ -73,6 +73,7 @@ import { FullRecipeView } from '../../models/Recipe.model'; }) export class RecipeEditForm implements OnInit { public readonly recipe = input.required(); + public readonly editSlugDisabled = input(false); public readonly submitRecipe = output(); public readonly deleteDraft = output(); @@ -105,6 +106,9 @@ export class RecipeEditForm implements OnInit { private readonly injector = inject(Injector); public ngOnInit(): void { + if (this.editSlugDisabled()) { + this.recipeFormGroup.controls.slug.disable(); + } const draft = this.recipe(); this.recipeFormGroup.patchValue({ title: draft.title ?? '', @@ -122,6 +126,9 @@ export class RecipeEditForm implements OnInit { })), ); } + if (draft.mainImage) { + this.mainImage.set(draft.mainImage); + } runInInjectionContext(this.injector, () => { afterNextRender({ mixedReadWrite: () => { diff --git a/src/app/shared/services/RecipeService.ts b/src/app/shared/services/RecipeService.ts index dec28f6..ec0124c 100644 --- a/src/app/shared/services/RecipeService.ts +++ b/src/app/shared/services/RecipeService.ts @@ -10,6 +10,7 @@ import { EndpointService } from './EndpointService'; import { SliceView } from '../models/SliceView.model'; import { WithStringDates } from '../util'; import { ImageService } from './ImageService'; +import { RecipeUpdateBody } from '../bodies/RecipeUpdateBody'; @Injectable({ providedIn: 'root', @@ -32,6 +33,19 @@ export class RecipeService { }; } + private hydrateFullRecipeViewWrapper(raw: WithStringDates): FullRecipeViewWrapper { + return { + ...raw, + recipe: { + ...raw.recipe, + created: new Date(raw.recipe.created), + modified: raw.recipe.modified ? new Date(raw.recipe.modified) : undefined, + ingredients: raw.recipe.ingredients!, // TODO: investigate why we need this + mainImage: raw.recipe.mainImage ? this.imageService.hydrateImageView(raw.recipe.mainImage) : undefined, + }, + }; + } + public getRecipes(): Observable> { return this.http.get>>(this.endpointService.getUrl('recipes')).pipe( map((sliceView) => ({ @@ -47,6 +61,34 @@ export class RecipeService { ); } + public getRecipeView2( + username: string, + slug: string, + includeRawText: boolean = false, + ): Observable { + return this.http + .get>( + this.endpointService.getUrl('recipes', [username, slug], { + custom: { + includeRawText, + }, + }), + ) + .pipe(map((raw) => this.hydrateFullRecipeViewWrapper(raw))); + } + + public updateRecipe( + username: string, + slug: string, + recipeUpdateBody: RecipeUpdateBody, + ): Observable { + return this.http + .put< + WithStringDates + >(this.endpointService.getUrl('recipes', [username, slug]), recipeUpdateBody) + .pipe(map((raw) => this.hydrateFullRecipeViewWrapper(raw))); + } + private getRecipeBaseUrl(recipeView: FullRecipeViewWrapper): string { return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]); }