Compare commits
No commits in common. "e071b0ed8c3c36ef79e80ee2527291758ee3f4e5" and "ac000de6a5beb8765fda7bb1382ed61d8ee38c96" have entirely different histories.
e071b0ed8c
...
ac000de6a5
@ -4,7 +4,6 @@ import { RecipesPage } from './pages/recipes-page/recipes-page';
|
||||
import { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page';
|
||||
import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page';
|
||||
import { authGuard } from './shared/guards/auth-guard';
|
||||
import { RecipeEditPage } from './pages/recipe-edit-page/recipe-edit-page';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@ -24,8 +23,4 @@ export const routes: Routes = [
|
||||
path: 'recipes/:username/:slug',
|
||||
component: RecipePage,
|
||||
},
|
||||
{
|
||||
path: 'recipes/:username/:slug/edit',
|
||||
component: RecipeEditPage,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
<h1>Edit Recipe</h1>
|
||||
@if (loadingRecipe()) {
|
||||
<app-spinner></app-spinner>
|
||||
} @else if (loadRecipeError()) {
|
||||
<p>There was an error loading the recipe: {{ loadRecipeError() }}</p>
|
||||
} @else {
|
||||
@let recipe = recipeView()!.recipe;
|
||||
<div class="recipe-info-container">
|
||||
<p>Created: {{ recipe.created | date: "short" }}</p>
|
||||
@if (recipe.modified) {
|
||||
<p>Last modified: {{ recipe.modified | date: "short" }}</p>
|
||||
}
|
||||
</div>
|
||||
<app-recipe-edit-form
|
||||
[recipe]="recipe"
|
||||
[editSlugDisabled]="true"
|
||||
(submitRecipe)="onRecipeSubmit($event)"
|
||||
></app-recipe-edit-form>
|
||||
@if (submittingRecipe()) {
|
||||
<app-spinner></app-spinner>
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RecipeEditPage } from './recipe-edit-page';
|
||||
|
||||
describe('RecipeEditPage', () => {
|
||||
let component: RecipeEditPage;
|
||||
let fixture: ComponentFixture<RecipeEditPage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RecipeEditPage],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(RecipeEditPage);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@ -1,73 +0,0 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { FullRecipeViewWrapper } from '../../shared/models/Recipe.model';
|
||||
import { RecipeService } from '../../shared/services/RecipeService';
|
||||
import { Spinner } from '../../shared/components/spinner/spinner';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { RecipeEditForm } from '../../shared/components/recipe-edit-form/recipe-edit-form';
|
||||
import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-edit-page',
|
||||
imports: [Spinner, DatePipe, RecipeEditForm],
|
||||
templateUrl: './recipe-edit-page.html',
|
||||
styleUrl: './recipe-edit-page.css',
|
||||
})
|
||||
export class RecipeEditPage implements OnInit {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly recipeService = inject(RecipeService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
|
||||
protected readonly loadingRecipe = signal(false);
|
||||
protected readonly loadRecipeError = signal<Error | null>(null);
|
||||
protected readonly recipeView = signal<FullRecipeViewWrapper | null>(null);
|
||||
|
||||
protected readonly submittingRecipe = signal(false);
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||
const username = paramMap.get('username')!;
|
||||
const slug = paramMap.get('slug')!;
|
||||
this.loadingRecipe.set(true);
|
||||
this.recipeService.getRecipeView2(username, slug, true).subscribe({
|
||||
next: (recipeView) => {
|
||||
this.loadingRecipe.set(false);
|
||||
this.recipeView.set(recipeView);
|
||||
},
|
||||
error: (e) => {
|
||||
this.loadingRecipe.set(false);
|
||||
this.loadRecipeError.set(e);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onRecipeSubmit(event: RecipeEditFormSubmitEvent): void {
|
||||
this.submittingRecipe.set(true);
|
||||
const baseRecipe = this.recipeView()!.recipe;
|
||||
this.recipeService
|
||||
.updateRecipe(baseRecipe.owner.username, baseRecipe.slug, {
|
||||
...event,
|
||||
mainImage: event.mainImage
|
||||
? {
|
||||
username: event.mainImage.owner.username,
|
||||
filename: event.mainImage.filename,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
.subscribe({
|
||||
next: async () => {
|
||||
this.submittingRecipe.set(false);
|
||||
await this.router.navigate(['recipes', baseRecipe.owner.username, baseRecipe.slug]);
|
||||
this.toastrService.success('Recipe updated');
|
||||
},
|
||||
error: (e) => {
|
||||
this.submittingRecipe.set(false);
|
||||
console.error(e);
|
||||
this.toastrService.error('Error submitting recipe');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -23,7 +23,6 @@
|
||||
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #recipeActionsMenu="matMenu">
|
||||
<button mat-menu-item (click)="onRecipeEdit()">Edit recipe</button>
|
||||
<button mat-menu-item (click)="onRecipeDelete()">Delete recipe</button>
|
||||
</mat-menu>
|
||||
}
|
||||
|
||||
@ -59,11 +59,6 @@ export class RecipePageContent {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly toastrService = inject(ToastrService);
|
||||
|
||||
protected async onRecipeEdit(): Promise<void> {
|
||||
const recipe = this.recipeView().recipe;
|
||||
await this.router.navigate(['recipes', recipe.owner.username, recipe.slug, 'edit']);
|
||||
}
|
||||
|
||||
protected onRecipeDelete(): void {
|
||||
const dialogRef = this.dialog.open<ConfirmationDialog, ConfirmationDialogData, boolean>(ConfirmationDialog, {
|
||||
data: {
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
||||
<app-enter-recipe-data
|
||||
[draft]="model().draft!"
|
||||
(recipeSubmit)="onEnterRecipeDataSubmit($event)"
|
||||
(submit)="onEnterRecipeDataSubmit($event)"
|
||||
(deleteDraft)="onDeleteDraft()"
|
||||
></app-enter-recipe-data>
|
||||
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
|
||||
|
||||
@ -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 { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
||||
import { EnterRecipeDataSubmitEvent } from './steps/enter-recipe-data/EnterRecipeDataSubmitEvent';
|
||||
import { QueryClient } from '@tanstack/angular-query-experimental';
|
||||
|
||||
@Component({
|
||||
@ -140,7 +140,7 @@ export class RecipeUploadPage implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
protected async onEnterRecipeDataSubmit(event: RecipeEditFormSubmitEvent): Promise<void> {
|
||||
protected async onEnterRecipeDataSubmit(event: EnterRecipeDataSubmitEvent): Promise<void> {
|
||||
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
|
||||
await this.switchModel(model, RecipeUploadStep.REVIEW);
|
||||
}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
||||
|
||||
export type EnterRecipeDataSubmitEvent = Omit<
|
||||
RecipeDraftViewModel,
|
||||
'id' | 'created' | 'modified' | 'state' | 'owner' | 'lastInference'
|
||||
>;
|
||||
@ -1,3 +1,16 @@
|
||||
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;
|
||||
@ -7,3 +20,25 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@ -13,4 +13,130 @@
|
||||
</mat-menu>
|
||||
</div>
|
||||
</div>
|
||||
<app-recipe-edit-form [recipe]="draft()" (submitRecipe)="onSubmit($event)"></app-recipe-edit-form>
|
||||
<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="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,6 +3,12 @@ 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;
|
||||
@ -17,13 +23,41 @@ 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({
|
||||
imports: [EnterRecipeData],
|
||||
providers: [provideQueryClient(queryClient)],
|
||||
providers: [
|
||||
provideQueryClient(queryClient),
|
||||
{
|
||||
provide: ImageService,
|
||||
useValue: imageServiceMock,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EnterRecipeData, {
|
||||
bindings: [inputBinding('draft', () => ({}) as RecipeDraftViewModel)],
|
||||
bindings: [
|
||||
inputBinding(
|
||||
'draft',
|
||||
() =>
|
||||
({
|
||||
id: 'test-id',
|
||||
created: new Date(),
|
||||
state: 'ENTER_DATA',
|
||||
owner: {} as UserInfoView,
|
||||
}) satisfies RecipeDraftViewModel,
|
||||
),
|
||||
],
|
||||
});
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
@ -32,4 +66,51 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,41 +1,253 @@
|
||||
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 {
|
||||
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 { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { EnterRecipeDataSubmitEvent } from './EnterRecipeDataSubmitEvent';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { RecipeEditForm } from '../../../../shared/components/recipe-edit-form/recipe-edit-form';
|
||||
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,
|
||||
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 { 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,
|
||||
MatMenuTrigger,
|
||||
FaIconComponent,
|
||||
MatMenuTrigger,
|
||||
MatMenu,
|
||||
MatMenuItem,
|
||||
RecipeEditForm,
|
||||
MatTable,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
MatHeaderCellDef,
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
DatePipe,
|
||||
ImageSelect,
|
||||
MatError,
|
||||
],
|
||||
templateUrl: './enter-recipe-data.html',
|
||||
styleUrl: './enter-recipe-data.css',
|
||||
})
|
||||
export class EnterRecipeData {
|
||||
export class EnterRecipeData implements OnInit {
|
||||
public readonly draft = input.required<RecipeDraftViewModel>();
|
||||
public readonly recipeSubmit = output<RecipeEditFormSubmitEvent>();
|
||||
public readonly submit = output<EnterRecipeDataSubmitEvent>();
|
||||
public readonly deleteDraft = output<void>();
|
||||
|
||||
protected onSubmit(event: RecipeEditFormSubmitEvent): void {
|
||||
this.recipeSubmit.emit(event);
|
||||
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.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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
export interface RecipeUpdateBody {
|
||||
title?: string | null;
|
||||
preparationTime?: number | null;
|
||||
cookingTime?: number | null;
|
||||
totalTime?: number | null;
|
||||
ingredients?: IngredientUpdateBody[] | null;
|
||||
rawText?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
mainImage?: MainImageUpdateBody | null;
|
||||
}
|
||||
|
||||
export interface IngredientUpdateBody {
|
||||
amount?: string | null;
|
||||
name: string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface MainImageUpdateBody {
|
||||
username: string;
|
||||
filename: string;
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,127 +0,0 @@
|
||||
<form [formGroup]="recipeFormGroup" (submit)="onSubmit($event)">
|
||||
<h3>Basic Info</h3>
|
||||
<mat-form-field>
|
||||
<mat-label>Title</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.title" />
|
||||
</mat-form-field>
|
||||
<mat-form-field>
|
||||
<mat-label>Slug</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.slug" />
|
||||
</mat-form-field>
|
||||
|
||||
<div class="ingredients-container">
|
||||
<h3>Ingredients</h3>
|
||||
|
||||
<table
|
||||
#ingredientsTable
|
||||
mat-table
|
||||
[dataSource]="ingredientModels()"
|
||||
cdkDropList
|
||||
(cdkDropListDropped)="onIngredientDrop($event)"
|
||||
>
|
||||
<ng-container matColumnDef="reorder">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef>
|
||||
<fa-icon [icon]="faBars"></fa-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="amount">
|
||||
<th mat-header-cell *matHeaderCellDef>Amount</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
{{ model.draft.amount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
{{ model.draft.name }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="notes">
|
||||
<th mat-header-cell *matHeaderCellDef>Notes</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
{{ model.draft.notes }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let model">
|
||||
<button matButton="text" [matMenuTriggerFor]="ingredientActionsMenu" type="button">
|
||||
<fa-icon [icon]="faEllipsis" size="2x"></fa-icon>
|
||||
</button>
|
||||
<mat-menu #ingredientActionsMenu="matMenu">
|
||||
<button mat-menu-item (click)="editIngredient(model)" type="button">Edit</button>
|
||||
<button mat-menu-item (click)="onIngredientDelete(model)" type="button">Delete</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="['reorder', 'amount', 'name', 'notes', 'actions']"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: ['reorder', 'amount', 'name', 'notes', 'actions']"
|
||||
cdkDrag
|
||||
[cdkDragData]="row"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<button matButton="outlined" (click)="addIngredient()" type="button">Add Ingredient</button>
|
||||
</div>
|
||||
|
||||
<h3>Images</h3>
|
||||
<button matButton="outlined" (click)="openImageUploadDialog()" type="button">Upload Image</button>
|
||||
|
||||
<h4>Select Main Image</h4>
|
||||
<app-image-select
|
||||
(select)="onMainImageSelect($event)"
|
||||
[selectedUsernameFilename]="mainImageUsernameFilename()"
|
||||
></app-image-select>
|
||||
|
||||
<div class="times-container">
|
||||
<h3>Times</h3>
|
||||
<p>Enter all as number of minutes, <em>eg.</em> 45</p>
|
||||
<mat-form-field>
|
||||
<mat-label>Preparation Time (minutes)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[formControl]="recipeFormGroup.controls.preparationTime"
|
||||
data-test-role="preparation-time-input"
|
||||
/>
|
||||
@if (recipeFormGroup.controls.preparationTime.hasError("pattern")) {
|
||||
<mat-error data-test-role="preparation-time-error">Must be a valid number.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Cooking Time (minutes)</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.cookingTime" data-test-role="cooking-time-input" />
|
||||
@if (recipeFormGroup.controls.cookingTime.hasError("pattern")) {
|
||||
<mat-error data-test-role="cooking-time-error">Must be a valid number.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>Total Time (minutes)</mat-label>
|
||||
<input matInput [formControl]="recipeFormGroup.controls.totalTime" data-test-role="total-time-input" />
|
||||
@if (recipeFormGroup.controls.totalTime.hasError("pattern")) {
|
||||
<mat-error data-test-role="total-time-error">Must be a valid number.</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<h3>Recipe Text</h3>
|
||||
<mat-form-field>
|
||||
<mat-label>Recipe Text</mat-label>
|
||||
<textarea
|
||||
#recipeTextTextarea
|
||||
matInput
|
||||
[formControl]="recipeFormGroup.controls.text"
|
||||
(input)="onRecipeTextChange($event)"
|
||||
></textarea>
|
||||
</mat-form-field>
|
||||
<button matButton="filled" type="submit" [disabled]="recipeFormGroup.invalid">Submit</button>
|
||||
</form>
|
||||
@ -1,117 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -1,255 +0,0 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
input,
|
||||
OnInit,
|
||||
output,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { ImageSelect } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import {
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatHeaderCell,
|
||||
MatHeaderCellDef,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatTable,
|
||||
} from '@angular/material/table';
|
||||
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
|
||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { faBars, faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
||||
import { IngredientDraftClientModel } from '../../client-models/IngredientDraftClientModel';
|
||||
import { ImageView } from '../../models/ImageView.model';
|
||||
import { IngredientDialog } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog';
|
||||
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
|
||||
import { RecipeEditFormSubmitEvent } from './RecipeEditFormSubmitEvent';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ImageUploadDialog } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog';
|
||||
import { FullRecipeView } from '../../models/Recipe.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recipe-edit-form',
|
||||
imports: [
|
||||
CdkDrag,
|
||||
CdkDropList,
|
||||
FaIconComponent,
|
||||
ImageSelect,
|
||||
MatButton,
|
||||
MatCell,
|
||||
MatCellDef,
|
||||
MatColumnDef,
|
||||
MatError,
|
||||
MatFormField,
|
||||
MatHeaderCell,
|
||||
MatHeaderRow,
|
||||
MatHeaderRowDef,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
MatMenu,
|
||||
MatMenuItem,
|
||||
MatRow,
|
||||
MatRowDef,
|
||||
MatTable,
|
||||
ReactiveFormsModule,
|
||||
MatMenuTrigger,
|
||||
MatHeaderCellDef,
|
||||
],
|
||||
templateUrl: './recipe-edit-form.html',
|
||||
styleUrl: './recipe-edit-form.css',
|
||||
})
|
||||
export class RecipeEditForm implements OnInit {
|
||||
public readonly recipe = input.required<RecipeDraftViewModel | FullRecipeView>();
|
||||
public readonly editSlugDisabled = input<boolean>(false);
|
||||
public readonly submitRecipe = output<RecipeEditFormSubmitEvent>();
|
||||
public readonly deleteDraft = output<void>();
|
||||
|
||||
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
|
||||
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
protected readonly recipeFormGroup = new FormGroup({
|
||||
title: new FormControl('', Validators.required),
|
||||
slug: new FormControl('', Validators.required),
|
||||
preparationTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||
cookingTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||
totalTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||
text: new FormControl('', Validators.required),
|
||||
});
|
||||
|
||||
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
|
||||
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
|
||||
|
||||
private readonly mainImage = signal<ImageView | null>(null);
|
||||
protected readonly mainImageUsernameFilename = computed(() => {
|
||||
const mainImage = this.mainImage();
|
||||
if (mainImage) {
|
||||
return [mainImage.owner.username, mainImage.filename] as const;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
public ngOnInit(): void {
|
||||
if (this.editSlugDisabled()) {
|
||||
this.recipeFormGroup.controls.slug.disable();
|
||||
}
|
||||
const draft = this.recipe();
|
||||
this.recipeFormGroup.patchValue({
|
||||
title: draft.title ?? '',
|
||||
slug: draft.slug ?? '',
|
||||
preparationTime: draft.preparationTime?.toString() ?? '',
|
||||
cookingTime: draft.cookingTime?.toString() ?? '',
|
||||
totalTime: draft.totalTime?.toString() ?? '',
|
||||
text: draft.rawText ?? '',
|
||||
});
|
||||
if (draft.ingredients) {
|
||||
this.ingredientModels.set(
|
||||
draft.ingredients.map((ingredient, index) => ({
|
||||
id: index,
|
||||
draft: ingredient,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if (draft.mainImage) {
|
||||
this.mainImage.set(draft.mainImage);
|
||||
}
|
||||
runInInjectionContext(this.injector, () => {
|
||||
afterNextRender({
|
||||
mixedReadWrite: () => {
|
||||
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
|
||||
const windowScrollX = window.scrollX;
|
||||
const windowScrollY = window.scrollY;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(windowScrollX, windowScrollY);
|
||||
});
|
||||
}
|
||||
|
||||
protected onRecipeTextChange(event: Event): void {
|
||||
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
|
||||
}
|
||||
|
||||
protected addIngredient(): void {
|
||||
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
||||
IngredientDialog,
|
||||
{
|
||||
data: {
|
||||
id: this.ingredientModels().length,
|
||||
draft: {
|
||||
name: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
dialogRef.afterClosed().subscribe((ingredientModel) => {
|
||||
if (ingredientModel) {
|
||||
this.ingredientModels.update((ingredientModels) => [...ingredientModels, ingredientModel]);
|
||||
this.ingredientsTable()!.renderRows();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected editIngredient(model: IngredientDraftClientModel): void {
|
||||
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
||||
IngredientDialog,
|
||||
{
|
||||
data: model,
|
||||
},
|
||||
);
|
||||
dialogRef.afterClosed().subscribe((model) => {
|
||||
if (model) {
|
||||
this.ingredientModels.update((models) => {
|
||||
const updated: IngredientDraftClientModel[] = [...models];
|
||||
const target = updated.find((search) => search.id === model.id);
|
||||
if (!target) {
|
||||
throw new Error('Ingredient does not exist.');
|
||||
}
|
||||
target.draft = model.draft;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected onIngredientDelete(ingredientModel: IngredientDraftClientModel) {
|
||||
this.ingredientModels.update((ingredientModels) => {
|
||||
const updated = ingredientModels.filter((model) => model.id !== ingredientModel.id);
|
||||
updated.sort((model0, model1) => model0.id - model1.id);
|
||||
updated.forEach((model, index) => {
|
||||
model.id = index;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
protected onIngredientDrop(event: CdkDragDrop<IngredientDraftClientModel>): void {
|
||||
this.ingredientModels.update((ingredientModels) => {
|
||||
const modelIndex = ingredientModels.findIndex((m) => m.id === event.previousIndex);
|
||||
moveItemInArray(ingredientModels, modelIndex, event.currentIndex);
|
||||
ingredientModels.forEach((model, index) => {
|
||||
model.id = index;
|
||||
});
|
||||
return ingredientModels;
|
||||
});
|
||||
this.ingredientsTable()!.renderRows();
|
||||
}
|
||||
|
||||
private getTime(s?: string | null): number | null {
|
||||
if (!s) return null;
|
||||
try {
|
||||
return parseInt(s);
|
||||
} catch (e) {
|
||||
console.error(`Should not have had a parse error because of form validators: ${e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected onSubmit(event: SubmitEvent): void {
|
||||
event.preventDefault();
|
||||
const value = this.recipeFormGroup.value;
|
||||
this.submitRecipe.emit({
|
||||
title: value.title!,
|
||||
slug: value.slug!,
|
||||
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
|
||||
preparationTime: this.getTime(value.preparationTime),
|
||||
cookingTime: this.getTime(value.cookingTime),
|
||||
totalTime: this.getTime(value.totalTime),
|
||||
mainImage: this.mainImage(),
|
||||
rawText: value.text!,
|
||||
});
|
||||
}
|
||||
|
||||
protected openImageUploadDialog(): void {
|
||||
this.dialog.open(ImageUploadDialog);
|
||||
}
|
||||
|
||||
protected onMainImageSelect(imageView: ImageView | null): void {
|
||||
this.mainImage.set(imageView);
|
||||
}
|
||||
|
||||
protected readonly faEllipsis = faEllipsis;
|
||||
protected readonly faBars = faBars;
|
||||
}
|
||||
@ -10,7 +10,6 @@ import { EndpointService } from './EndpointService';
|
||||
import { SliceView } from '../models/SliceView.model';
|
||||
import { WithStringDates } from '../util';
|
||||
import { ImageService } from './ImageService';
|
||||
import { RecipeUpdateBody } from '../bodies/RecipeUpdateBody';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@ -33,19 +32,6 @@ export class RecipeService {
|
||||
};
|
||||
}
|
||||
|
||||
private hydrateFullRecipeViewWrapper(raw: WithStringDates<FullRecipeViewWrapper>): FullRecipeViewWrapper {
|
||||
return {
|
||||
...raw,
|
||||
recipe: {
|
||||
...raw.recipe,
|
||||
created: new Date(raw.recipe.created),
|
||||
modified: raw.recipe.modified ? new Date(raw.recipe.modified) : undefined,
|
||||
ingredients: raw.recipe.ingredients!, // TODO: investigate why we need this
|
||||
mainImage: raw.recipe.mainImage ? this.imageService.hydrateImageView(raw.recipe.mainImage) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public getRecipes(): Observable<SliceView<RecipeInfoView>> {
|
||||
return this.http.get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes')).pipe(
|
||||
map((sliceView) => ({
|
||||
@ -61,34 +47,6 @@ export class RecipeService {
|
||||
);
|
||||
}
|
||||
|
||||
public getRecipeView2(
|
||||
username: string,
|
||||
slug: string,
|
||||
includeRawText: boolean = false,
|
||||
): Observable<FullRecipeViewWrapper> {
|
||||
return this.http
|
||||
.get<WithStringDates<FullRecipeViewWrapper>>(
|
||||
this.endpointService.getUrl('recipes', [username, slug], {
|
||||
custom: {
|
||||
includeRawText,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.pipe(map((raw) => this.hydrateFullRecipeViewWrapper(raw)));
|
||||
}
|
||||
|
||||
public updateRecipe(
|
||||
username: string,
|
||||
slug: string,
|
||||
recipeUpdateBody: RecipeUpdateBody,
|
||||
): Observable<FullRecipeViewWrapper> {
|
||||
return this.http
|
||||
.put<
|
||||
WithStringDates<FullRecipeViewWrapper>
|
||||
>(this.endpointService.getUrl('recipes', [username, slug]), recipeUpdateBody)
|
||||
.pipe(map((raw) => this.hydrateFullRecipeViewWrapper(raw)));
|
||||
}
|
||||
|
||||
private getRecipeBaseUrl(recipeView: FullRecipeViewWrapper): string {
|
||||
return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user