MME-14 Basic recipe edit page.

This commit is contained in:
Jesse Brault 2026-02-15 13:59:38 -06:00
parent 73bb3a7ab8
commit e071b0ed8c
11 changed files with 199 additions and 1 deletions

View File

@ -4,6 +4,7 @@ import { RecipesPage } from './pages/recipes-page/recipes-page';
import { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page'; import { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page';
import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page'; import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page';
import { authGuard } from './shared/guards/auth-guard'; import { authGuard } from './shared/guards/auth-guard';
import { RecipeEditPage } from './pages/recipe-edit-page/recipe-edit-page';
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -23,4 +24,8 @@ export const routes: Routes = [
path: 'recipes/:username/:slug', path: 'recipes/:username/:slug',
component: RecipePage, component: RecipePage,
}, },
{
path: 'recipes/:username/:slug/edit',
component: RecipeEditPage,
},
]; ];

View File

@ -0,0 +1,22 @@
<h1>Edit Recipe</h1>
@if (loadingRecipe()) {
<app-spinner></app-spinner>
} @else if (loadRecipeError()) {
<p>There was an error loading the recipe: {{ loadRecipeError() }}</p>
} @else {
@let recipe = recipeView()!.recipe;
<div class="recipe-info-container">
<p>Created: {{ recipe.created | date: "short" }}</p>
@if (recipe.modified) {
<p>Last modified: {{ recipe.modified | date: "short" }}</p>
}
</div>
<app-recipe-edit-form
[recipe]="recipe"
[editSlugDisabled]="true"
(submitRecipe)="onRecipeSubmit($event)"
></app-recipe-edit-form>
@if (submittingRecipe()) {
<app-spinner></app-spinner>
}
}

View File

@ -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<RecipeEditPage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RecipeEditPage],
}).compileComponents();
fixture = TestBed.createComponent(RecipeEditPage);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<Error | null>(null);
protected readonly recipeView = signal<FullRecipeViewWrapper | null>(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');
},
});
}
}

View File

@ -23,6 +23,7 @@
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon> <fa-icon [icon]="faEllipsis" size="3x"></fa-icon>
</button> </button>
<mat-menu #recipeActionsMenu="matMenu"> <mat-menu #recipeActionsMenu="matMenu">
<button mat-menu-item (click)="onRecipeEdit()">Edit recipe</button>
<button mat-menu-item (click)="onRecipeDelete()">Delete recipe</button> <button mat-menu-item (click)="onRecipeDelete()">Delete recipe</button>
</mat-menu> </mat-menu>
} }

View File

@ -59,6 +59,11 @@ export class RecipePageContent {
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly toastrService = inject(ToastrService); private readonly toastrService = inject(ToastrService);
protected async onRecipeEdit(): Promise<void> {
const recipe = this.recipeView().recipe;
await this.router.navigate(['recipes', recipe.owner.username, recipe.slug, 'edit']);
}
protected onRecipeDelete(): void { protected onRecipeDelete(): void {
const dialogRef = this.dialog.open<ConfirmationDialog, ConfirmationDialogData, boolean>(ConfirmationDialog, { const dialogRef = this.dialog.open<ConfirmationDialog, ConfirmationDialogData, boolean>(ConfirmationDialog, {
data: { data: {

View File

@ -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;
}

View File

@ -123,5 +123,5 @@
(input)="onRecipeTextChange($event)" (input)="onRecipeTextChange($event)"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
<button matButton="filled" type="submit" [disabled]="recipeFormGroup.invalid">Review</button> <button matButton="filled" type="submit" [disabled]="recipeFormGroup.invalid">Submit</button>
</form> </form>

View File

@ -73,6 +73,7 @@ import { FullRecipeView } from '../../models/Recipe.model';
}) })
export class RecipeEditForm implements OnInit { export class RecipeEditForm implements OnInit {
public readonly recipe = input.required<RecipeDraftViewModel | FullRecipeView>(); public readonly recipe = input.required<RecipeDraftViewModel | FullRecipeView>();
public readonly editSlugDisabled = input<boolean>(false);
public readonly submitRecipe = output<RecipeEditFormSubmitEvent>(); public readonly submitRecipe = output<RecipeEditFormSubmitEvent>();
public readonly deleteDraft = output<void>(); public readonly deleteDraft = output<void>();
@ -105,6 +106,9 @@ export class RecipeEditForm implements OnInit {
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
public ngOnInit(): void { public ngOnInit(): void {
if (this.editSlugDisabled()) {
this.recipeFormGroup.controls.slug.disable();
}
const draft = this.recipe(); const draft = this.recipe();
this.recipeFormGroup.patchValue({ this.recipeFormGroup.patchValue({
title: draft.title ?? '', title: draft.title ?? '',
@ -122,6 +126,9 @@ export class RecipeEditForm implements OnInit {
})), })),
); );
} }
if (draft.mainImage) {
this.mainImage.set(draft.mainImage);
}
runInInjectionContext(this.injector, () => { runInInjectionContext(this.injector, () => {
afterNextRender({ afterNextRender({
mixedReadWrite: () => { mixedReadWrite: () => {

View File

@ -10,6 +10,7 @@ import { EndpointService } from './EndpointService';
import { SliceView } from '../models/SliceView.model'; import { SliceView } from '../models/SliceView.model';
import { WithStringDates } from '../util'; import { WithStringDates } from '../util';
import { ImageService } from './ImageService'; import { ImageService } from './ImageService';
import { RecipeUpdateBody } from '../bodies/RecipeUpdateBody';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -32,6 +33,19 @@ export class RecipeService {
}; };
} }
private hydrateFullRecipeViewWrapper(raw: WithStringDates<FullRecipeViewWrapper>): 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<SliceView<RecipeInfoView>> { public getRecipes(): Observable<SliceView<RecipeInfoView>> {
return this.http.get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes')).pipe( return this.http.get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes')).pipe(
map((sliceView) => ({ map((sliceView) => ({
@ -47,6 +61,34 @@ export class RecipeService {
); );
} }
public getRecipeView2(
username: string,
slug: string,
includeRawText: boolean = false,
): Observable<FullRecipeViewWrapper> {
return this.http
.get<WithStringDates<FullRecipeViewWrapper>>(
this.endpointService.getUrl('recipes', [username, slug], {
custom: {
includeRawText,
},
}),
)
.pipe(map((raw) => this.hydrateFullRecipeViewWrapper(raw)));
}
public updateRecipe(
username: string,
slug: string,
recipeUpdateBody: RecipeUpdateBody,
): Observable<FullRecipeViewWrapper> {
return this.http
.put<
WithStringDates<FullRecipeViewWrapper>
>(this.endpointService.getUrl('recipes', [username, slug]), recipeUpdateBody)
.pipe(map((raw) => this.hydrateFullRecipeViewWrapper(raw)));
}
private getRecipeBaseUrl(recipeView: FullRecipeViewWrapper): string { private getRecipeBaseUrl(recipeView: FullRecipeViewWrapper): string {
return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]); return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]);
} }