Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse Brault
e071b0ed8c MME-14 Basic recipe edit page. 2026-02-15 14:00:05 -06:00
Jesse Brault
73bb3a7ab8 MME-14 Move recipe edit form to own component. 2026-02-15 13:05:08 -06:00
21 changed files with 757 additions and 480 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

@ -17,7 +17,7 @@
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) { } @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
<app-enter-recipe-data <app-enter-recipe-data
[draft]="model().draft!" [draft]="model().draft!"
(submit)="onEnterRecipeDataSubmit($event)" (recipeSubmit)="onEnterRecipeDataSubmit($event)"
(deleteDraft)="onDeleteDraft()" (deleteDraft)="onDeleteDraft()"
></app-enter-recipe-data> ></app-enter-recipe-data>
} @else if (displayStep() === RecipeUploadStep.REVIEW) { } @else if (displayStep() === RecipeUploadStep.REVIEW) {

View File

@ -14,7 +14,7 @@ import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadE
import { tryMaybeInt } from '../../shared/util'; import { tryMaybeInt } from '../../shared/util';
import { from, map, switchMap, tap } from 'rxjs'; import { from, map, switchMap, tap } from 'rxjs';
import { Review } from './steps/review/review'; import { Review } from './steps/review/review';
import { EnterRecipeDataSubmitEvent } from './steps/enter-recipe-data/EnterRecipeDataSubmitEvent'; import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
import { QueryClient } from '@tanstack/angular-query-experimental'; import { QueryClient } from '@tanstack/angular-query-experimental';
@Component({ @Component({
@ -140,7 +140,7 @@ export class RecipeUploadPage implements OnInit {
} }
} }
protected async onEnterRecipeDataSubmit(event: EnterRecipeDataSubmitEvent): Promise<void> { protected async onEnterRecipeDataSubmit(event: RecipeEditFormSubmitEvent): Promise<void> {
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event); const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
await this.switchModel(model, RecipeUploadStep.REVIEW); await this.switchModel(model, RecipeUploadStep.REVIEW);
} }

View File

@ -1,6 +0,0 @@
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
export type EnterRecipeDataSubmitEvent = Omit<
RecipeDraftViewModel,
'id' | 'created' | 'modified' | 'state' | 'owner' | 'lastInference'
>;

View File

@ -1,16 +1,3 @@
form {
display: flex;
flex-direction: column;
width: 60ch;
}
textarea {
box-sizing: border-box;
height: auto;
overflow: hidden;
resize: none;
}
.draft-info-container { .draft-info-container {
width: 60ch; width: 60ch;
display: flex; display: flex;
@ -20,25 +7,3 @@ textarea {
.draft-actions-button { .draft-actions-button {
padding: 0; padding: 0;
} }
.ingredients-container {
display: flex;
flex-direction: column;
row-gap: 10px;
}
.times-container {
display: flex;
flex-direction: column;
row-gap: 10px;
}
.mat-column-reorder {
width: 32px;
text-align: center;
}
.mat-column-actions {
width: 32px;
text-align: center;
}

View File

@ -13,130 +13,4 @@
</mat-menu> </mat-menu>
</div> </div>
</div> </div>
<form [formGroup]="recipeFormGroup" (submit)="onSubmit($event)"> <app-recipe-edit-form [recipe]="draft()" (submitRecipe)="onSubmit($event)"></app-recipe-edit-form>
<h3>Basic Info</h3>
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.title" />
</mat-form-field>
<mat-form-field>
<mat-label>Slug</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.slug" />
</mat-form-field>
<div class="ingredients-container">
<h3>Ingredients</h3>
<table
#ingredientsTable
mat-table
[dataSource]="ingredientModels()"
cdkDropList
(cdkDropListDropped)="onIngredientDrop($event)"
>
<ng-container matColumnDef="reorder">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let model">
<fa-icon [icon]="faBars"></fa-icon>
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let model">
{{ model.draft.amount }}
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let model">
{{ model.draft.name }}
</td>
</ng-container>
<ng-container matColumnDef="notes">
<th mat-header-cell *matHeaderCellDef>Notes</th>
<td mat-cell *matCellDef="let model">
{{ model.draft.notes }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let model">
<button matButton="text" [matMenuTriggerFor]="ingredientActionsMenu" type="button">
<fa-icon [icon]="faEllipsis" size="2x"></fa-icon>
</button>
<mat-menu #ingredientActionsMenu="matMenu">
<button mat-menu-item (click)="editIngredient(model)" type="button">Edit</button>
<button mat-menu-item (click)="onIngredientDelete(model)" type="button">Delete</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['reorder', 'amount', 'name', 'notes', 'actions']"></tr>
<tr
mat-row
*matRowDef="let row; columns: ['reorder', 'amount', 'name', 'notes', 'actions']"
cdkDrag
[cdkDragData]="row"
></tr>
</table>
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
</div>
<h3>Images</h3>
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
<h4>Select Main Image</h4>
<app-image-select
(select)="onMainImageSelect($event)"
[selectedUsernameFilename]="mainImageUsernameFilename()"
></app-image-select>
<div class="times-container">
<h3>Times</h3>
<p>Enter all as number of minutes, <em>eg.</em> 45</p>
<mat-form-field>
<mat-label>Preparation Time (minutes)</mat-label>
<input
matInput
[formControl]="recipeFormGroup.controls.preparationTime"
data-test-role="preparation-time-input"
/>
@if (recipeFormGroup.controls.preparationTime.hasError("pattern")) {
<mat-error data-test-role="preparation-time-error">Must be a valid number.</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>Cooking Time (minutes)</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.cookingTime" data-test-role="cooking-time-input" />
@if (recipeFormGroup.controls.cookingTime.hasError("pattern")) {
<mat-error data-test-role="cooking-time-error">Must be a valid number.</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>Total Time (minutes)</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.totalTime" data-test-role="total-time-input" />
@if (recipeFormGroup.controls.totalTime.hasError("pattern")) {
<mat-error data-test-role="total-time-error">Must be a valid number.</mat-error>
}
</mat-form-field>
</div>
<h3>Recipe Text</h3>
<mat-form-field>
<mat-label>Recipe Text</mat-label>
<textarea
#recipeTextTextarea
matInput
[formControl]="recipeFormGroup.controls.text"
(input)="onRecipeTextChange($event)"
></textarea>
</mat-form-field>
<button matButton="filled" type="submit" [disabled]="recipeFormGroup.invalid">Review</button>
</form>

View File

@ -3,12 +3,6 @@ import { EnterRecipeData } from './enter-recipe-data';
import { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental'; import { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental';
import { inputBinding } from '@angular/core'; import { inputBinding } from '@angular/core';
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model'; import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
import { UserInfoView } from '../../../../shared/models/UserInfoView.model';
import { ImageService } from '../../../../shared/services/ImageService';
import { of } from 'rxjs';
import { SliceView, SliceViewMeta } from '../../../../shared/models/SliceView.model';
import { ImageViewWithBlobUrl } from '../../../../shared/client-models/ImageViewWithBlobUrl';
import { By } from '@angular/platform-browser';
describe('EnterRecipeData', () => { describe('EnterRecipeData', () => {
let component: EnterRecipeData; let component: EnterRecipeData;
@ -23,41 +17,13 @@ describe('EnterRecipeData', () => {
}, },
}); });
const imageServiceMock = {
getOwnedImagesCount: vi.fn(() => of(0)),
getOwnedImageViewsWithBlobUrls: vi.fn(() =>
of({
count: 0,
slice: {} as SliceViewMeta,
content: [],
} as SliceView<ImageViewWithBlobUrl>),
),
} as Partial<ImageService>;
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [EnterRecipeData], imports: [EnterRecipeData],
providers: [ providers: [provideQueryClient(queryClient)],
provideQueryClient(queryClient),
{
provide: ImageService,
useValue: imageServiceMock,
},
],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(EnterRecipeData, { fixture = TestBed.createComponent(EnterRecipeData, {
bindings: [ bindings: [inputBinding('draft', () => ({}) as RecipeDraftViewModel)],
inputBinding(
'draft',
() =>
({
id: 'test-id',
created: new Date(),
state: 'ENTER_DATA',
owner: {} as UserInfoView,
}) satisfies RecipeDraftViewModel,
),
],
}); });
component = fixture.componentInstance; component = fixture.componentInstance;
await fixture.whenStable(); await fixture.whenStable();
@ -66,51 +32,4 @@ describe('EnterRecipeData', () => {
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); expect(component).toBeTruthy();
}); });
const testTimeInput = (describeBlockName: string, inputRole: string, errorRole: string) => {
describe(describeBlockName, () => {
it('should accept a number input with no error presented', () => {
const preparationTimeInputDebug = fixture.debugElement.query(By.css(`[data-test-role=${inputRole}]`));
expect(preparationTimeInputDebug).toBeTruthy();
const preparationTimeInput: HTMLInputElement = preparationTimeInputDebug.nativeElement;
preparationTimeInput.value = '1234';
preparationTimeInput.dispatchEvent(new Event('input'));
preparationTimeInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
});
it('should not output an error if touched but no input', () => {
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
By.css(`[data-test-role=${inputRole}]`),
).nativeElement;
preparationTimeInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
});
it('should display an error if non-number input', () => {
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
By.css(`[data-test-role=${inputRole}]`),
).nativeElement;
preparationTimeInput.value = 'abcd';
preparationTimeInput.dispatchEvent(new Event('input'));
preparationTimeInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
const errorDebug = fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`));
expect(errorDebug).toBeTruthy();
expect(errorDebug.nativeElement.textContent).toContain('Must be a valid number.');
});
});
};
describe('time inputs', () => {
testTimeInput('preparation time', 'preparation-time-input', 'preparation-time-error');
testTimeInput('cooking time', 'cooking-time-input', 'cooking-time-error');
testTimeInput('total time', 'total-time-input', 'total-time-error');
});
}); });

View File

@ -1,253 +1,41 @@
import { import { Component, input, output } from '@angular/core';
afterNextRender, import { ReactiveFormsModule } from '@angular/forms';
Component, import { RecipeEditFormSubmitEvent } from '../../../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
computed, import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
ElementRef,
inject,
Injector,
input,
OnInit,
output,
runInInjectionContext,
signal,
viewChild,
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { EnterRecipeDataSubmitEvent } from './EnterRecipeDataSubmitEvent';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { faBars, faEllipsis } from '@fortawesome/free-solid-svg-icons';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { MatDialog } from '@angular/material/dialog';
import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog';
import { IngredientDialog } from './ingredient-dialog/ingredient-dialog';
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model'; import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
import { import { MatButton } from '@angular/material/button';
MatCell, import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
MatCellDef, import { FaIconComponent } from '@fortawesome/angular-fontawesome';
MatColumnDef, import { RecipeEditForm } from '../../../../shared/components/recipe-edit-form/recipe-edit-form';
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable,
} from '@angular/material/table';
import { IngredientDraftClientModel } from '../../../../shared/client-models/IngredientDraftClientModel';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { ImageSelect } from './image-select/image-select';
import { ImageView } from '../../../../shared/models/ImageView.model';
@Component({ @Component({
selector: 'app-enter-recipe-data', selector: 'app-enter-recipe-data',
imports: [ imports: [
ReactiveFormsModule, ReactiveFormsModule,
MatFormField,
MatLabel,
MatInput,
MatButton, MatButton,
FaIconComponent,
MatMenuTrigger, MatMenuTrigger,
FaIconComponent,
MatMenu, MatMenu,
MatMenuItem, MatMenuItem,
MatTable, RecipeEditForm,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatCell,
MatCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
CdkDropList,
CdkDrag,
DatePipe, DatePipe,
ImageSelect,
MatError,
], ],
templateUrl: './enter-recipe-data.html', templateUrl: './enter-recipe-data.html',
styleUrl: './enter-recipe-data.css', styleUrl: './enter-recipe-data.css',
}) })
export class EnterRecipeData implements OnInit { export class EnterRecipeData {
public readonly draft = input.required<RecipeDraftViewModel>(); public readonly draft = input.required<RecipeDraftViewModel>();
public readonly submit = output<EnterRecipeDataSubmitEvent>(); public readonly recipeSubmit = output<RecipeEditFormSubmitEvent>();
public readonly deleteDraft = output<void>(); public readonly deleteDraft = output<void>();
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea'); protected onSubmit(event: RecipeEditFormSubmitEvent): void {
this.recipeSubmit.emit(event);
private readonly dialog = inject(MatDialog);
protected readonly recipeFormGroup = new FormGroup({
title: new FormControl('', Validators.required),
slug: new FormControl('', Validators.required),
preparationTime: new FormControl('', Validators.pattern(/^\d+$/)),
cookingTime: new FormControl('', Validators.pattern(/^\d+$/)),
totalTime: new FormControl('', Validators.pattern(/^\d+$/)),
text: new FormControl('', Validators.required),
});
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
private readonly mainImage = signal<ImageView | null>(null);
protected readonly mainImageUsernameFilename = computed(() => {
const mainImage = this.mainImage();
if (mainImage) {
return [mainImage.owner.username, mainImage.filename] as const;
} else {
return null;
}
});
private readonly injector = inject(Injector);
public ngOnInit(): void {
const draft = this.draft();
this.recipeFormGroup.patchValue({
title: draft.title ?? '',
slug: draft.slug ?? '',
preparationTime: draft.preparationTime?.toString() ?? '',
cookingTime: draft.cookingTime?.toString() ?? '',
totalTime: draft.totalTime?.toString() ?? '',
text: draft.rawText ?? '',
});
if (draft.ingredients) {
this.ingredientModels.set(
draft.ingredients.map((ingredient, index) => ({
id: index,
draft: ingredient,
})),
);
}
runInInjectionContext(this.injector, () => {
afterNextRender({
mixedReadWrite: () => {
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
},
});
});
}
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
const windowScrollX = window.scrollX;
const windowScrollY = window.scrollY;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
requestAnimationFrame(() => {
window.scrollTo(windowScrollX, windowScrollY);
});
}
protected onRecipeTextChange(event: Event): void {
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
}
protected addIngredient() {
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
IngredientDialog,
{
data: {
id: this.ingredientModels().length,
draft: {
name: '',
},
},
},
);
dialogRef.afterClosed().subscribe((ingredientModel) => {
if (ingredientModel) {
this.ingredientModels.update((ingredientModels) => [...ingredientModels, ingredientModel]);
this.ingredientsTable()!.renderRows();
}
});
}
protected editIngredient(model: IngredientDraftClientModel): void {
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
IngredientDialog,
{
data: model,
},
);
dialogRef.afterClosed().subscribe((model) => {
if (model) {
this.ingredientModels.update((models) => {
const updated: IngredientDraftClientModel[] = [...models];
const target = updated.find((search) => search.id === model.id);
if (!target) {
throw new Error('Ingredient does not exist.');
}
target.draft = model.draft;
return updated;
});
}
});
}
protected onIngredientDelete(ingredientModel: IngredientDraftClientModel) {
this.ingredientModels.update((ingredientModels) => {
const updated = ingredientModels.filter((model) => model.id !== ingredientModel.id);
updated.sort((model0, model1) => model0.id - model1.id);
updated.forEach((model, index) => {
model.id = index;
});
return updated;
});
}
protected onIngredientDrop(event: CdkDragDrop<IngredientDraftClientModel>): void {
this.ingredientModels.update((ingredientModels) => {
const modelIndex = ingredientModels.findIndex((m) => m.id === event.previousIndex);
moveItemInArray(ingredientModels, modelIndex, event.currentIndex);
ingredientModels.forEach((model, index) => {
model.id = index;
});
return ingredientModels;
});
this.ingredientsTable()!.renderRows();
}
private getTime(s?: string | null): number | null {
if (!s) return null;
try {
return parseInt(s);
} catch (e) {
console.error(`Should not have had a parse error because of form validators: ${e}`);
return null;
}
}
protected onSubmit(event: SubmitEvent): void {
event.preventDefault();
const value = this.recipeFormGroup.value;
this.submit.emit({
title: value.title!,
slug: value.slug!,
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
preparationTime: this.getTime(value.preparationTime),
cookingTime: this.getTime(value.cookingTime),
totalTime: this.getTime(value.totalTime),
mainImage: this.mainImage(),
rawText: value.text!,
});
} }
protected onDraftDelete(): void { protected onDraftDelete(): void {
this.deleteDraft.emit(); this.deleteDraft.emit();
} }
protected openImageUploadDialog(): void {
this.dialog.open(ImageUploadDialog);
}
protected onMainImageSelect(imageView: ImageView | null): void {
this.mainImage.set(imageView);
}
protected readonly faEllipsis = faEllipsis; protected readonly faEllipsis = faEllipsis;
protected readonly faBars = faBars;
} }

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

@ -0,0 +1,13 @@
import { IngredientDraft } from '../../models/RecipeDraftView.model';
import { ImageView } from '../../models/ImageView.model';
export interface RecipeEditFormSubmitEvent {
title: string;
slug: string;
ingredients: IngredientDraft[];
preparationTime: number | null;
cookingTime: number | null;
totalTime: number | null;
mainImage: ImageView | null;
rawText: string;
}

View File

@ -0,0 +1,34 @@
form {
display: flex;
flex-direction: column;
width: 60ch;
}
textarea {
box-sizing: border-box;
height: auto;
overflow: hidden;
resize: none;
}
.ingredients-container {
display: flex;
flex-direction: column;
row-gap: 10px;
}
.times-container {
display: flex;
flex-direction: column;
row-gap: 10px;
}
.mat-column-reorder {
width: 32px;
text-align: center;
}
.mat-column-actions {
width: 32px;
text-align: center;
}

View File

@ -0,0 +1,127 @@
<form [formGroup]="recipeFormGroup" (submit)="onSubmit($event)">
<h3>Basic Info</h3>
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.title" />
</mat-form-field>
<mat-form-field>
<mat-label>Slug</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.slug" />
</mat-form-field>
<div class="ingredients-container">
<h3>Ingredients</h3>
<table
#ingredientsTable
mat-table
[dataSource]="ingredientModels()"
cdkDropList
(cdkDropListDropped)="onIngredientDrop($event)"
>
<ng-container matColumnDef="reorder">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef>
<fa-icon [icon]="faBars"></fa-icon>
</td>
</ng-container>
<ng-container matColumnDef="amount">
<th mat-header-cell *matHeaderCellDef>Amount</th>
<td mat-cell *matCellDef="let model">
{{ model.draft.amount }}
</td>
</ng-container>
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let model">
{{ model.draft.name }}
</td>
</ng-container>
<ng-container matColumnDef="notes">
<th mat-header-cell *matHeaderCellDef>Notes</th>
<td mat-cell *matCellDef="let model">
{{ model.draft.notes }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>Actions</th>
<td mat-cell *matCellDef="let model">
<button matButton="text" [matMenuTriggerFor]="ingredientActionsMenu" type="button">
<fa-icon [icon]="faEllipsis" size="2x"></fa-icon>
</button>
<mat-menu #ingredientActionsMenu="matMenu">
<button mat-menu-item (click)="editIngredient(model)" type="button">Edit</button>
<button mat-menu-item (click)="onIngredientDelete(model)" type="button">Delete</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="['reorder', 'amount', 'name', 'notes', 'actions']"></tr>
<tr
mat-row
*matRowDef="let row; columns: ['reorder', 'amount', 'name', 'notes', 'actions']"
cdkDrag
[cdkDragData]="row"
></tr>
</table>
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
</div>
<h3>Images</h3>
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
<h4>Select Main Image</h4>
<app-image-select
(select)="onMainImageSelect($event)"
[selectedUsernameFilename]="mainImageUsernameFilename()"
></app-image-select>
<div class="times-container">
<h3>Times</h3>
<p>Enter all as number of minutes, <em>eg.</em> 45</p>
<mat-form-field>
<mat-label>Preparation Time (minutes)</mat-label>
<input
matInput
[formControl]="recipeFormGroup.controls.preparationTime"
data-test-role="preparation-time-input"
/>
@if (recipeFormGroup.controls.preparationTime.hasError("pattern")) {
<mat-error data-test-role="preparation-time-error">Must be a valid number.</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>Cooking Time (minutes)</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.cookingTime" data-test-role="cooking-time-input" />
@if (recipeFormGroup.controls.cookingTime.hasError("pattern")) {
<mat-error data-test-role="cooking-time-error">Must be a valid number.</mat-error>
}
</mat-form-field>
<mat-form-field>
<mat-label>Total Time (minutes)</mat-label>
<input matInput [formControl]="recipeFormGroup.controls.totalTime" data-test-role="total-time-input" />
@if (recipeFormGroup.controls.totalTime.hasError("pattern")) {
<mat-error data-test-role="total-time-error">Must be a valid number.</mat-error>
}
</mat-form-field>
</div>
<h3>Recipe Text</h3>
<mat-form-field>
<mat-label>Recipe Text</mat-label>
<textarea
#recipeTextTextarea
matInput
[formControl]="recipeFormGroup.controls.text"
(input)="onRecipeTextChange($event)"
></textarea>
</mat-form-field>
<button matButton="filled" type="submit" [disabled]="recipeFormGroup.invalid">Submit</button>
</form>

View File

@ -0,0 +1,117 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RecipeEditForm } from './recipe-edit-form';
import { By } from '@angular/platform-browser';
import { inputBinding } from '@angular/core';
import { UserInfoView } from '../../models/UserInfoView.model';
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
import { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental';
import { ImageService } from '../../services/ImageService';
import { of } from 'rxjs';
import { SliceView, SliceViewMeta } from '../../models/SliceView.model';
import { ImageViewWithBlobUrl } from '../../client-models/ImageViewWithBlobUrl';
describe('RecipeEditForm', () => {
let component: RecipeEditForm;
let fixture: ComponentFixture<RecipeEditForm>;
beforeEach(async () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const imageServiceMock = {
getOwnedImagesCount: vi.fn(() => of(0)),
getOwnedImageViewsWithBlobUrls: vi.fn(() =>
of({
count: 0,
slice: {} as SliceViewMeta,
content: [],
} as SliceView<ImageViewWithBlobUrl>),
),
} as Partial<ImageService>;
await TestBed.configureTestingModule({
imports: [RecipeEditForm],
providers: [
provideQueryClient(queryClient),
{
provide: ImageService,
useValue: imageServiceMock,
},
],
}).compileComponents();
fixture = TestBed.createComponent(RecipeEditForm, {
bindings: [
inputBinding(
'recipe',
() =>
({
id: 'test-id',
created: new Date(),
state: 'ENTER_DATA',
owner: {} as UserInfoView,
}) satisfies RecipeDraftViewModel,
),
],
});
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
const testTimeInput = (describeBlockName: string, inputRole: string, errorRole: string) => {
describe(describeBlockName, () => {
it('should accept a number input with no error presented', () => {
const preparationTimeInputDebug = fixture.debugElement.query(By.css(`[data-test-role=${inputRole}]`));
expect(preparationTimeInputDebug).toBeTruthy();
const preparationTimeInput: HTMLInputElement = preparationTimeInputDebug.nativeElement;
preparationTimeInput.value = '1234';
preparationTimeInput.dispatchEvent(new Event('input'));
preparationTimeInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
});
it('should not output an error if touched but no input', () => {
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
By.css(`[data-test-role=${inputRole}]`),
).nativeElement;
preparationTimeInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
});
it('should display an error if non-number input', () => {
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
By.css(`[data-test-role=${inputRole}]`),
).nativeElement;
preparationTimeInput.value = 'abcd';
preparationTimeInput.dispatchEvent(new Event('input'));
preparationTimeInput.dispatchEvent(new Event('blur'));
fixture.detectChanges();
const errorDebug = fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`));
expect(errorDebug).toBeTruthy();
expect(errorDebug.nativeElement.textContent).toContain('Must be a valid number.');
});
});
};
describe('time inputs', () => {
testTimeInput('preparation time', 'preparation-time-input', 'preparation-time-error');
testTimeInput('cooking time', 'cooking-time-input', 'cooking-time-error');
testTimeInput('total time', 'total-time-input', 'total-time-error');
});
});

View File

@ -0,0 +1,255 @@
import {
afterNextRender,
Component,
computed,
ElementRef,
inject,
Injector,
input,
OnInit,
output,
runInInjectionContext,
signal,
viewChild,
} from '@angular/core';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ImageSelect } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select';
import { MatButton } from '@angular/material/button';
import {
MatCell,
MatCellDef,
MatColumnDef,
MatHeaderCell,
MatHeaderCellDef,
MatHeaderRow,
MatHeaderRowDef,
MatRow,
MatRowDef,
MatTable,
} from '@angular/material/table';
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { faBars, faEllipsis } from '@fortawesome/free-solid-svg-icons';
import { IngredientDraftClientModel } from '../../client-models/IngredientDraftClientModel';
import { ImageView } from '../../models/ImageView.model';
import { IngredientDialog } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog';
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
import { RecipeEditFormSubmitEvent } from './RecipeEditFormSubmitEvent';
import { MatDialog } from '@angular/material/dialog';
import { ImageUploadDialog } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog';
import { FullRecipeView } from '../../models/Recipe.model';
@Component({
selector: 'app-recipe-edit-form',
imports: [
CdkDrag,
CdkDropList,
FaIconComponent,
ImageSelect,
MatButton,
MatCell,
MatCellDef,
MatColumnDef,
MatError,
MatFormField,
MatHeaderCell,
MatHeaderRow,
MatHeaderRowDef,
MatInput,
MatLabel,
MatMenu,
MatMenuItem,
MatRow,
MatRowDef,
MatTable,
ReactiveFormsModule,
MatMenuTrigger,
MatHeaderCellDef,
],
templateUrl: './recipe-edit-form.html',
styleUrl: './recipe-edit-form.css',
})
export class RecipeEditForm implements OnInit {
public readonly recipe = input.required<RecipeDraftViewModel | FullRecipeView>();
public readonly editSlugDisabled = input<boolean>(false);
public readonly submitRecipe = output<RecipeEditFormSubmitEvent>();
public readonly deleteDraft = output<void>();
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
private readonly dialog = inject(MatDialog);
protected readonly recipeFormGroup = new FormGroup({
title: new FormControl('', Validators.required),
slug: new FormControl('', Validators.required),
preparationTime: new FormControl('', Validators.pattern(/^\d+$/)),
cookingTime: new FormControl('', Validators.pattern(/^\d+$/)),
totalTime: new FormControl('', Validators.pattern(/^\d+$/)),
text: new FormControl('', Validators.required),
});
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
private readonly mainImage = signal<ImageView | null>(null);
protected readonly mainImageUsernameFilename = computed(() => {
const mainImage = this.mainImage();
if (mainImage) {
return [mainImage.owner.username, mainImage.filename] as const;
} else {
return null;
}
});
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 ?? '',
slug: draft.slug ?? '',
preparationTime: draft.preparationTime?.toString() ?? '',
cookingTime: draft.cookingTime?.toString() ?? '',
totalTime: draft.totalTime?.toString() ?? '',
text: draft.rawText ?? '',
});
if (draft.ingredients) {
this.ingredientModels.set(
draft.ingredients.map((ingredient, index) => ({
id: index,
draft: ingredient,
})),
);
}
if (draft.mainImage) {
this.mainImage.set(draft.mainImage);
}
runInInjectionContext(this.injector, () => {
afterNextRender({
mixedReadWrite: () => {
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
},
});
});
}
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
const windowScrollX = window.scrollX;
const windowScrollY = window.scrollY;
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 'px';
requestAnimationFrame(() => {
window.scrollTo(windowScrollX, windowScrollY);
});
}
protected onRecipeTextChange(event: Event): void {
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
}
protected addIngredient(): void {
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
IngredientDialog,
{
data: {
id: this.ingredientModels().length,
draft: {
name: '',
},
},
},
);
dialogRef.afterClosed().subscribe((ingredientModel) => {
if (ingredientModel) {
this.ingredientModels.update((ingredientModels) => [...ingredientModels, ingredientModel]);
this.ingredientsTable()!.renderRows();
}
});
}
protected editIngredient(model: IngredientDraftClientModel): void {
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
IngredientDialog,
{
data: model,
},
);
dialogRef.afterClosed().subscribe((model) => {
if (model) {
this.ingredientModels.update((models) => {
const updated: IngredientDraftClientModel[] = [...models];
const target = updated.find((search) => search.id === model.id);
if (!target) {
throw new Error('Ingredient does not exist.');
}
target.draft = model.draft;
return updated;
});
}
});
}
protected onIngredientDelete(ingredientModel: IngredientDraftClientModel) {
this.ingredientModels.update((ingredientModels) => {
const updated = ingredientModels.filter((model) => model.id !== ingredientModel.id);
updated.sort((model0, model1) => model0.id - model1.id);
updated.forEach((model, index) => {
model.id = index;
});
return updated;
});
}
protected onIngredientDrop(event: CdkDragDrop<IngredientDraftClientModel>): void {
this.ingredientModels.update((ingredientModels) => {
const modelIndex = ingredientModels.findIndex((m) => m.id === event.previousIndex);
moveItemInArray(ingredientModels, modelIndex, event.currentIndex);
ingredientModels.forEach((model, index) => {
model.id = index;
});
return ingredientModels;
});
this.ingredientsTable()!.renderRows();
}
private getTime(s?: string | null): number | null {
if (!s) return null;
try {
return parseInt(s);
} catch (e) {
console.error(`Should not have had a parse error because of form validators: ${e}`);
return null;
}
}
protected onSubmit(event: SubmitEvent): void {
event.preventDefault();
const value = this.recipeFormGroup.value;
this.submitRecipe.emit({
title: value.title!,
slug: value.slug!,
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
preparationTime: this.getTime(value.preparationTime),
cookingTime: this.getTime(value.cookingTime),
totalTime: this.getTime(value.totalTime),
mainImage: this.mainImage(),
rawText: value.text!,
});
}
protected openImageUploadDialog(): void {
this.dialog.open(ImageUploadDialog);
}
protected onMainImageSelect(imageView: ImageView | null): void {
this.mainImage.set(imageView);
}
protected readonly faEllipsis = faEllipsis;
protected readonly faBars = faBars;
}

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