diff --git a/src/app/pages/recipe-upload-page/recipe-upload-page.html b/src/app/pages/recipe-upload-page/recipe-upload-page.html index 37390b5..b9af2e1 100644 --- a/src/app/pages/recipe-upload-page/recipe-upload-page.html +++ b/src/app/pages/recipe-upload-page/recipe-upload-page.html @@ -17,7 +17,7 @@ } @else if (displayStep() === RecipeUploadStep.ENTER_DATA) { } @else if (displayStep() === RecipeUploadStep.REVIEW) { diff --git a/src/app/pages/recipe-upload-page/recipe-upload-page.ts b/src/app/pages/recipe-upload-page/recipe-upload-page.ts index 453d7b6..7b5bdf9 100644 --- a/src/app/pages/recipe-upload-page/recipe-upload-page.ts +++ b/src/app/pages/recipe-upload-page/recipe-upload-page.ts @@ -14,7 +14,7 @@ import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadE import { tryMaybeInt } from '../../shared/util'; import { from, map, switchMap, tap } from 'rxjs'; 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'; @Component({ @@ -140,7 +140,7 @@ export class RecipeUploadPage implements OnInit { } } - protected async onEnterRecipeDataSubmit(event: EnterRecipeDataSubmitEvent): Promise { + protected async onEnterRecipeDataSubmit(event: RecipeEditFormSubmitEvent): Promise { const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event); await this.switchModel(model, RecipeUploadStep.REVIEW); } diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/EnterRecipeDataSubmitEvent.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/EnterRecipeDataSubmitEvent.ts deleted file mode 100644 index 1c249c5..0000000 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/EnterRecipeDataSubmitEvent.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model'; - -export type EnterRecipeDataSubmitEvent = Omit< - RecipeDraftViewModel, - 'id' | 'created' | 'modified' | 'state' | 'owner' | 'lastInference' ->; diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css index 81e7dc5..3cd1ba2 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.css @@ -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 { width: 60ch; display: flex; @@ -20,25 +7,3 @@ textarea { .draft-actions-button { 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; -} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html index 45b5a4a..01b9e1b 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.html @@ -13,130 +13,4 @@ -
-

Basic Info

- - Title - - - - Slug - - - -
-

Ingredients

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Amount - {{ model.draft.amount }} - Name - {{ model.draft.name }} - Notes - {{ model.draft.notes }} - Actions - - - - - -
- - -
- -

Images

- - -

Select Main Image

- - -
-

Times

-

Enter all as number of minutes, eg. 45

- - Preparation Time (minutes) - - @if (recipeFormGroup.controls.preparationTime.hasError("pattern")) { - Must be a valid number. - } - - - - Cooking Time (minutes) - - @if (recipeFormGroup.controls.cookingTime.hasError("pattern")) { - Must be a valid number. - } - - - - Total Time (minutes) - - @if (recipeFormGroup.controls.totalTime.hasError("pattern")) { - Must be a valid number. - } - -
- -

Recipe Text

- - Recipe Text - - - -
+ diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.spec.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.spec.ts index b695f73..7ebf01d 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.spec.ts +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.spec.ts @@ -3,12 +3,6 @@ import { EnterRecipeData } from './enter-recipe-data'; import { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental'; import { inputBinding } from '@angular/core'; 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', () => { 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), - ), - } as Partial; - await TestBed.configureTestingModule({ imports: [EnterRecipeData], - providers: [ - provideQueryClient(queryClient), - { - provide: ImageService, - useValue: imageServiceMock, - }, - ], + providers: [provideQueryClient(queryClient)], }).compileComponents(); fixture = TestBed.createComponent(EnterRecipeData, { - bindings: [ - inputBinding( - 'draft', - () => - ({ - id: 'test-id', - created: new Date(), - state: 'ENTER_DATA', - owner: {} as UserInfoView, - }) satisfies RecipeDraftViewModel, - ), - ], + bindings: [inputBinding('draft', () => ({}) as RecipeDraftViewModel)], }); component = fixture.componentInstance; await fixture.whenStable(); @@ -66,51 +32,4 @@ describe('EnterRecipeData', () => { 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'); - }); }); diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts index 692f354..84ee512 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/enter-recipe-data.ts @@ -1,253 +1,41 @@ -import { - afterNextRender, - Component, - computed, - 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 { Component, input, output } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RecipeEditFormSubmitEvent } from '../../../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent'; +import { faEllipsis } from '@fortawesome/free-solid-svg-icons'; import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model'; -import { - MatCell, - MatCellDef, - MatColumnDef, - 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 { MatButton } from '@angular/material/button'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { RecipeEditForm } from '../../../../shared/components/recipe-edit-form/recipe-edit-form'; import { DatePipe } from '@angular/common'; -import { ImageSelect } from './image-select/image-select'; -import { ImageView } from '../../../../shared/models/ImageView.model'; @Component({ selector: 'app-enter-recipe-data', imports: [ ReactiveFormsModule, - MatFormField, - MatLabel, - MatInput, MatButton, - FaIconComponent, MatMenuTrigger, + FaIconComponent, MatMenu, MatMenuItem, - MatTable, - MatColumnDef, - MatHeaderCell, - MatHeaderCellDef, - MatCell, - MatCellDef, - MatHeaderRow, - MatHeaderRowDef, - MatRow, - MatRowDef, - CdkDropList, - CdkDrag, + RecipeEditForm, DatePipe, - ImageSelect, - MatError, ], templateUrl: './enter-recipe-data.html', styleUrl: './enter-recipe-data.css', }) -export class EnterRecipeData implements OnInit { +export class EnterRecipeData { public readonly draft = input.required(); - public readonly submit = output(); + public readonly recipeSubmit = output(); public readonly deleteDraft = output(); - protected readonly recipeTextTextarea = viewChild.required>('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>('ingredientsTable'); - protected readonly ingredientModels = signal([]); - - private readonly mainImage = signal(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, - { - 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, - { - 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): 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 onSubmit(event: RecipeEditFormSubmitEvent): void { + this.recipeSubmit.emit(event); } protected onDraftDelete(): void { 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 faBars = faBars; } diff --git a/src/app/shared/components/recipe-edit-form/RecipeEditFormSubmitEvent.ts b/src/app/shared/components/recipe-edit-form/RecipeEditFormSubmitEvent.ts new file mode 100644 index 0000000..1d6d512 --- /dev/null +++ b/src/app/shared/components/recipe-edit-form/RecipeEditFormSubmitEvent.ts @@ -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; +} diff --git a/src/app/shared/components/recipe-edit-form/recipe-edit-form.css b/src/app/shared/components/recipe-edit-form/recipe-edit-form.css new file mode 100644 index 0000000..17726b2 --- /dev/null +++ b/src/app/shared/components/recipe-edit-form/recipe-edit-form.css @@ -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; +} diff --git a/src/app/shared/components/recipe-edit-form/recipe-edit-form.html b/src/app/shared/components/recipe-edit-form/recipe-edit-form.html new file mode 100644 index 0000000..5c21b09 --- /dev/null +++ b/src/app/shared/components/recipe-edit-form/recipe-edit-form.html @@ -0,0 +1,127 @@ +
+

Basic Info

+ + Title + + + + Slug + + + +
+

Ingredients

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Amount + {{ model.draft.amount }} + Name + {{ model.draft.name }} + Notes + {{ model.draft.notes }} + Actions + + + + + +
+ + +
+ +

Images

+ + +

Select Main Image

+ + +
+

Times

+

Enter all as number of minutes, eg. 45

+ + Preparation Time (minutes) + + @if (recipeFormGroup.controls.preparationTime.hasError("pattern")) { + Must be a valid number. + } + + + + Cooking Time (minutes) + + @if (recipeFormGroup.controls.cookingTime.hasError("pattern")) { + Must be a valid number. + } + + + + Total Time (minutes) + + @if (recipeFormGroup.controls.totalTime.hasError("pattern")) { + Must be a valid number. + } + +
+ +

Recipe Text

+ + Recipe Text + + + +
diff --git a/src/app/shared/components/recipe-edit-form/recipe-edit-form.spec.ts b/src/app/shared/components/recipe-edit-form/recipe-edit-form.spec.ts new file mode 100644 index 0000000..5cb00a6 --- /dev/null +++ b/src/app/shared/components/recipe-edit-form/recipe-edit-form.spec.ts @@ -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; + + 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), + ), + } as Partial; + + 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'); + }); +}); diff --git a/src/app/shared/components/recipe-edit-form/recipe-edit-form.ts b/src/app/shared/components/recipe-edit-form/recipe-edit-form.ts new file mode 100644 index 0000000..95dbef8 --- /dev/null +++ b/src/app/shared/components/recipe-edit-form/recipe-edit-form.ts @@ -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(); + public readonly submitRecipe = output(); + public readonly deleteDraft = output(); + + protected readonly recipeTextTextarea = viewChild.required>('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>('ingredientsTable'); + protected readonly ingredientModels = signal([]); + + private readonly mainImage = signal(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, + { + 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, + { + 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): 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; +}