MME-14 Move recipe edit form to own component.

This commit is contained in:
Jesse Brault 2026-02-15 13:05:08 -06:00
parent ac000de6a5
commit 73bb3a7ab8
12 changed files with 559 additions and 480 deletions

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,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">Review</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,248 @@
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 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 {
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,
})),
);
}
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;
}