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 01e5643..37390b5 100644 --- a/src/app/pages/recipe-upload-page/recipe-upload-page.html +++ b/src/app/pages/recipe-upload-page/recipe-upload-page.html @@ -16,7 +16,7 @@ } @else if (displayStep() === RecipeUploadStep.ENTER_DATA) { 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 index a7fbc6e..bbe05fd 100644 --- 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 @@ -2,9 +2,9 @@ export interface EnterRecipeDataSubmitEvent { title: string; slug: string; ingredients: Array<{ - amount: string | null; + amount?: string | null; name: string; - notes: string | null; + notes?: string | null; }>; rawText: string; } 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 e84c155..f0915a6 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,11 +1,3 @@ -.ingredients-table { - width: 100ch; -} - -.ingredient-input { - width: 100%; -} - form { display: flex; flex-direction: column; @@ -20,6 +12,21 @@ textarea { } .draft-info-container { + width: 60ch; display: flex; - column-gap: 10px; + justify-content: space-between; +} + +.draft-actions-button { + padding: 0; +} + +.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 3ba338f..d99ab15 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 @@ -1,11 +1,11 @@

Enter Recipe

-

Draft started: {{ model().draft!.created }}

-

Last saved: {{ model().draft!.modified }}

+

Draft started: {{ draft().created | date: "short" }}

+

Last saved: {{ draft().modified | date: "short" }}

- @@ -25,57 +25,62 @@

Ingredients

+ + + + + + - - - - - + + + + + + +
+ + Amount - - - + + {{ model.draft.amount }} Name - - - + + {{ model.draft.name }} Notes - - - + + {{ model.draft.notes }}
Actions + + + + + +
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 e424e9e..e57691b 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 @@ -8,14 +8,20 @@ import { OnInit, output, runInInjectionContext, + signal, viewChild, - viewChildren, } from '@angular/core'; -import { RecipeUploadClientModel } from '../../../../shared/client-models/RecipeUploadClientModel'; -import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { 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 { MatCell, MatCellDef, @@ -28,11 +34,9 @@ import { MatRowDef, MatTable, } from '@angular/material/table'; -import { FaIconComponent } from '@fortawesome/angular-fontawesome'; -import { 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 { IngredientDraftClientModel } from '../../../../shared/client-models/IngredientDraftClientModel'; +import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; +import { DatePipe } from '@angular/common'; @Component({ selector: 'app-enter-recipe-data', @@ -42,6 +46,10 @@ import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog'; MatLabel, MatInput, MatButton, + FaIconComponent, + MatMenuTrigger, + MatMenu, + MatMenuItem, MatTable, MatColumnDef, MatHeaderCell, @@ -52,60 +60,48 @@ import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog'; MatHeaderRowDef, MatRow, MatRowDef, - FaIconComponent, - MatMenuTrigger, - MatMenu, - MatMenuItem, + CdkDropList, + CdkDrag, + DatePipe, ], templateUrl: './enter-recipe-data.html', styleUrl: './enter-recipe-data.css', }) export class EnterRecipeData implements OnInit { - public readonly model = input.required(); + public readonly draft = input.required(); public readonly submit = output(); public readonly deleteDraft = output(); protected readonly recipeTextTextarea = viewChild.required>('recipeTextTextarea'); - protected readonly ingredientsTable = viewChild.required< - MatTable< - FormGroup<{ - amount: FormControl; - name: FormControl; - notes: FormControl; - }> - > - >('ingredientsTable'); - protected readonly ingredientAmountControls = viewChildren>('ingredientAmount'); private readonly dialog = inject(MatDialog); protected readonly recipeFormGroup = new FormGroup({ title: new FormControl('', Validators.required), slug: new FormControl('', Validators.required), - ingredients: new FormArray( - [] as Array< - FormGroup<{ - amount: FormControl; - name: FormControl; - notes: FormControl; - }> - >, - ), text: new FormControl('', Validators.required), }); - protected readonly ingredientsColumnsToDisplay = ['amount', 'name', 'notes']; + protected readonly ingredientsTable = viewChild>('ingredientsTable'); + protected readonly ingredientModels = signal([]); private readonly injector = inject(Injector); public ngOnInit(): void { - const model = this.model(); + const draft = this.draft(); this.recipeFormGroup.patchValue({ - title: model.draft?.title ?? '', - slug: model.draft?.slug ?? '', - text: model.draft?.rawText ?? '', - ingredients: model.draft?.ingredients ?? [], + title: draft.title ?? '', + slug: draft.slug ?? '', + text: draft.rawText ?? '', }); + if (draft.ingredients) { + this.ingredientModels.set( + draft.ingredients.map((ingredient, index) => ({ + id: index, + draft: ingredient, + })), + ); + } runInInjectionContext(this.injector, () => { afterNextRender({ mixedReadWrite: () => { @@ -130,46 +126,68 @@ export class EnterRecipeData implements OnInit { } protected addIngredient() { - const control = new FormGroup({ - amount: new FormControl(''), - name: new FormControl('', Validators.required), - notes: new FormControl(''), + 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(); + } }); - this.recipeFormGroup.controls.ingredients.push(control); - this.ingredientsTable().renderRows(); - const addedIndex = this.recipeFormGroup.controls.ingredients.length - 1; - const target = this.ingredientAmountControls()[addedIndex]; - target.nativeElement.focus(); } - protected removeIngredient(index: number) { - this.recipeFormGroup.controls.ingredients.removeAt(index); + 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 getIngredientControl(index: number, column: 'amount' | 'name' | 'notes'): FormControl { - const ingredientGroup = this.recipeFormGroup.controls.ingredients.controls[index].controls; - switch (column) { - case 'amount': - return ingredientGroup.amount; - case 'name': - return ingredientGroup.name; - case 'notes': - return ingredientGroup.notes; - } + 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 onIngredientKeydown(event: KeyboardEvent, index: number) { - if (event.key === 'Enter') { - event.preventDefault(); - this.onIngredientEnterKey(index); - } - } - - private onIngredientEnterKey(index: number) { - if (index === this.recipeFormGroup.controls.ingredients.length - 1) { - // last control row - this.addIngredient(); - } + 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(); } protected onSubmit(event: SubmitEvent): void { @@ -178,12 +196,7 @@ export class EnterRecipeData implements OnInit { this.submit.emit({ title: value.title!, slug: value.slug!, - ingredients: - value.ingredients?.map((ingredient) => ({ - amount: ingredient.amount ?? null, - name: ingredient.name!, - notes: ingredient.notes ?? null, - })) ?? [], + ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft), rawText: value.text!, }); } @@ -197,4 +210,5 @@ export class EnterRecipeData implements OnInit { } protected readonly faEllipsis = faEllipsis; + protected readonly faBars = faBars; } diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html index 1fc2921..eb8171e 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.html @@ -1,5 +1,4 @@ -
-

Image Upload

+
@let file = fileToUpload(); @if (file !== null) { @@ -46,4 +45,4 @@
-
+ diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.ts index a0833ad..124666c 100644 --- a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.ts +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog.ts @@ -10,6 +10,7 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula import { MatCheckbox } from '@angular/material/checkbox'; import { MatDialogRef } from '@angular/material/dialog'; import { ImageDoesNotExistValidator } from '../../../../../shared/validators/image-does-not-exist-validator'; +import { DialogContainer } from '../../../../../shared/components/dialog-container/dialog-container'; @Component({ selector: 'app-image-upload-dialog', @@ -23,6 +24,7 @@ import { ImageDoesNotExistValidator } from '../../../../../shared/validators/ima ReactiveFormsModule, MatCheckbox, MatError, + DialogContainer, ], templateUrl: './image-upload-dialog.html', styleUrl: './image-upload-dialog.css', diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.css b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.css new file mode 100644 index 0000000..b9cb93b --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.css @@ -0,0 +1,5 @@ +form { + display: flex; + flex-direction: column; + row-gap: 10px; +} diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.html b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.html new file mode 100644 index 0000000..576b3d8 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.html @@ -0,0 +1,17 @@ + +
+ + Amount + + + + Name + + + + Notes + + + +
+
diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.spec.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.spec.ts new file mode 100644 index 0000000..b04dd01 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { IngredientDialog } from './ingredient-dialog'; + +describe('IngredientDialog', () => { + let component: IngredientDialog; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [IngredientDialog], + }).compileComponents(); + + fixture = TestBed.createComponent(IngredientDialog); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.ts b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.ts new file mode 100644 index 0000000..87e8402 --- /dev/null +++ b/src/app/pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog.ts @@ -0,0 +1,44 @@ +import { Component, inject, OnInit } from '@angular/core'; +import { DialogContainer } from '../../../../../shared/components/dialog-container/dialog-container'; +import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { IngredientDraftClientModel } from '../../../../../shared/client-models/IngredientDraftClientModel'; + +@Component({ + selector: 'app-ingredient-dialog', + imports: [DialogContainer, MatFormField, MatLabel, MatInput, ReactiveFormsModule, MatButton], + templateUrl: './ingredient-dialog.html', + styleUrl: './ingredient-dialog.css', +}) +export class IngredientDialog implements OnInit { + public readonly dialogRef = inject(MatDialogRef); + private readonly model = inject(MAT_DIALOG_DATA); + + protected readonly ingredientForm = new FormGroup({ + amount: new FormControl(''), + name: new FormControl('', Validators.required), + notes: new FormControl(''), + }); + + public ngOnInit(): void { + this.ingredientForm.patchValue({ + amount: this.model.draft.amount, + name: this.model.draft.name, + notes: this.model.draft.notes, + }); + } + + protected onSubmit(event: SubmitEvent): void { + event.preventDefault(); + this.dialogRef.close({ + ...this.model, + draft: { + amount: this.ingredientForm.value.amount, + name: this.ingredientForm.value.name, + notes: this.ingredientForm.value.notes, + }, + }); + } +} diff --git a/src/app/shared/client-models/IngredientDraftClientModel.ts b/src/app/shared/client-models/IngredientDraftClientModel.ts new file mode 100644 index 0000000..6fb7a4d --- /dev/null +++ b/src/app/shared/client-models/IngredientDraftClientModel.ts @@ -0,0 +1,6 @@ +import { IngredientDraft } from '../models/RecipeDraftView.model'; + +export interface IngredientDraftClientModel { + id: number; + draft: IngredientDraft; +} diff --git a/src/app/shared/components/dialog-container/dialog-container.css b/src/app/shared/components/dialog-container/dialog-container.css new file mode 100644 index 0000000..0ef2a90 --- /dev/null +++ b/src/app/shared/components/dialog-container/dialog-container.css @@ -0,0 +1,6 @@ +.dialog-container { + padding: 20px; + display: flex; + flex-direction: column; + row-gap: 10px; +} diff --git a/src/app/shared/components/dialog-container/dialog-container.html b/src/app/shared/components/dialog-container/dialog-container.html new file mode 100644 index 0000000..7836b8f --- /dev/null +++ b/src/app/shared/components/dialog-container/dialog-container.html @@ -0,0 +1,4 @@ +
+

{{ title() }}

+ +
diff --git a/src/app/shared/components/dialog-container/dialog-container.spec.ts b/src/app/shared/components/dialog-container/dialog-container.spec.ts new file mode 100644 index 0000000..c799e73 --- /dev/null +++ b/src/app/shared/components/dialog-container/dialog-container.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DialogContainer } from './dialog-container'; + +describe('DialogContainer', () => { + let component: DialogContainer; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DialogContainer], + }).compileComponents(); + + fixture = TestBed.createComponent(DialogContainer); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/dialog-container/dialog-container.ts b/src/app/shared/components/dialog-container/dialog-container.ts new file mode 100644 index 0000000..b9b1d95 --- /dev/null +++ b/src/app/shared/components/dialog-container/dialog-container.ts @@ -0,0 +1,11 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-dialog-container', + imports: [], + templateUrl: './dialog-container.html', + styleUrl: './dialog-container.css', +}) +export class DialogContainer { + public readonly title = input.required(); +}