MME-14 Move recipe edit form to own component.
This commit is contained in:
parent
ac000de6a5
commit
73bb3a7ab8
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
|
||||||
|
|
||||||
export type EnterRecipeDataSubmitEvent = Omit<
|
|
||||||
RecipeDraftViewModel,
|
|
||||||
'id' | 'created' | 'modified' | 'state' | 'owner' | 'lastInference'
|
|
||||||
>;
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
|
|||||||
@ -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');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
127
src/app/shared/components/recipe-edit-form/recipe-edit-form.html
Normal file
127
src/app/shared/components/recipe-edit-form/recipe-edit-form.html
Normal 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>
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
248
src/app/shared/components/recipe-edit-form/recipe-edit-form.ts
Normal file
248
src/app/shared/components/recipe-edit-form/recipe-edit-form.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user