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 { RecipesSearchPage } from './pages/recipes-search-page/recipes-search-page';
|
||||||
import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page';
|
import { RecipeUploadPage } from './pages/recipe-upload-page/recipe-upload-page';
|
||||||
import { authGuard } from './shared/guards/auth-guard';
|
import { authGuard } from './shared/guards/auth-guard';
|
||||||
import { RecipeEditPage } from './pages/recipe-edit-page/recipe-edit-page';
|
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@ -24,8 +23,4 @@ export const routes: Routes = [
|
|||||||
path: 'recipes/:username/:slug',
|
path: 'recipes/:username/:slug',
|
||||||
component: RecipePage,
|
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>
|
<fa-icon [icon]="faEllipsis" size="3x"></fa-icon>
|
||||||
</button>
|
</button>
|
||||||
<mat-menu #recipeActionsMenu="matMenu">
|
<mat-menu #recipeActionsMenu="matMenu">
|
||||||
<button mat-menu-item (click)="onRecipeEdit()">Edit recipe</button>
|
|
||||||
<button mat-menu-item (click)="onRecipeDelete()">Delete recipe</button>
|
<button mat-menu-item (click)="onRecipeDelete()">Delete recipe</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,11 +59,6 @@ export class RecipePageContent {
|
|||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly toastrService = inject(ToastrService);
|
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 {
|
protected onRecipeDelete(): void {
|
||||||
const dialogRef = this.dialog.open<ConfirmationDialog, ConfirmationDialogData, boolean>(ConfirmationDialog, {
|
const dialogRef = this.dialog.open<ConfirmationDialog, ConfirmationDialogData, boolean>(ConfirmationDialog, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
} @else if (displayStep() === RecipeUploadStep.ENTER_DATA) {
|
||||||
<app-enter-recipe-data
|
<app-enter-recipe-data
|
||||||
[draft]="model().draft!"
|
[draft]="model().draft!"
|
||||||
(recipeSubmit)="onEnterRecipeDataSubmit($event)"
|
(submit)="onEnterRecipeDataSubmit($event)"
|
||||||
(deleteDraft)="onDeleteDraft()"
|
(deleteDraft)="onDeleteDraft()"
|
||||||
></app-enter-recipe-data>
|
></app-enter-recipe-data>
|
||||||
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
|
} @else if (displayStep() === RecipeUploadStep.REVIEW) {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { FileUploadEvent } from '../../shared/components/file-upload/FileUploadE
|
|||||||
import { tryMaybeInt } from '../../shared/util';
|
import { tryMaybeInt } from '../../shared/util';
|
||||||
import { from, map, switchMap, tap } from 'rxjs';
|
import { from, map, switchMap, tap } from 'rxjs';
|
||||||
import { Review } from './steps/review/review';
|
import { Review } from './steps/review/review';
|
||||||
import { RecipeEditFormSubmitEvent } from '../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
import { EnterRecipeDataSubmitEvent } from './steps/enter-recipe-data/EnterRecipeDataSubmitEvent';
|
||||||
import { QueryClient } from '@tanstack/angular-query-experimental';
|
import { QueryClient } from '@tanstack/angular-query-experimental';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -140,7 +140,7 @@ export class RecipeUploadPage implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async onEnterRecipeDataSubmit(event: RecipeEditFormSubmitEvent): Promise<void> {
|
protected async onEnterRecipeDataSubmit(event: EnterRecipeDataSubmitEvent): Promise<void> {
|
||||||
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
|
const model = await this.recipeDraftService.updateDraft(this.model().draft!.id, event);
|
||||||
await this.switchModel(model, RecipeUploadStep.REVIEW);
|
await this.switchModel(model, RecipeUploadStep.REVIEW);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
.draft-info-container {
|
||||||
width: 60ch;
|
width: 60ch;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -7,3 +20,25 @@
|
|||||||
.draft-actions-button {
|
.draft-actions-button {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ingredients-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.times-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-column-reorder {
|
||||||
|
width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-column-actions {
|
||||||
|
width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|||||||
@ -13,4 +13,130 @@
|
|||||||
</mat-menu>
|
</mat-menu>
|
||||||
</div>
|
</div>
|
||||||
</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 { provideQueryClient, QueryClient } from '@tanstack/angular-query-experimental';
|
||||||
import { inputBinding } from '@angular/core';
|
import { inputBinding } from '@angular/core';
|
||||||
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
||||||
|
import { UserInfoView } from '../../../../shared/models/UserInfoView.model';
|
||||||
|
import { ImageService } from '../../../../shared/services/ImageService';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { SliceView, SliceViewMeta } from '../../../../shared/models/SliceView.model';
|
||||||
|
import { ImageViewWithBlobUrl } from '../../../../shared/client-models/ImageViewWithBlobUrl';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
|
||||||
describe('EnterRecipeData', () => {
|
describe('EnterRecipeData', () => {
|
||||||
let component: EnterRecipeData;
|
let component: EnterRecipeData;
|
||||||
@ -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({
|
await TestBed.configureTestingModule({
|
||||||
imports: [EnterRecipeData],
|
imports: [EnterRecipeData],
|
||||||
providers: [provideQueryClient(queryClient)],
|
providers: [
|
||||||
|
provideQueryClient(queryClient),
|
||||||
|
{
|
||||||
|
provide: ImageService,
|
||||||
|
useValue: imageServiceMock,
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(EnterRecipeData, {
|
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;
|
component = fixture.componentInstance;
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
@ -32,4 +66,51 @@ describe('EnterRecipeData', () => {
|
|||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const testTimeInput = (describeBlockName: string, inputRole: string, errorRole: string) => {
|
||||||
|
describe(describeBlockName, () => {
|
||||||
|
it('should accept a number input with no error presented', () => {
|
||||||
|
const preparationTimeInputDebug = fixture.debugElement.query(By.css(`[data-test-role=${inputRole}]`));
|
||||||
|
expect(preparationTimeInputDebug).toBeTruthy();
|
||||||
|
const preparationTimeInput: HTMLInputElement = preparationTimeInputDebug.nativeElement;
|
||||||
|
preparationTimeInput.value = '1234';
|
||||||
|
preparationTimeInput.dispatchEvent(new Event('input'));
|
||||||
|
preparationTimeInput.dispatchEvent(new Event('blur'));
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not output an error if touched but no input', () => {
|
||||||
|
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
|
||||||
|
By.css(`[data-test-role=${inputRole}]`),
|
||||||
|
).nativeElement;
|
||||||
|
preparationTimeInput.dispatchEvent(new Event('blur'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`))).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display an error if non-number input', () => {
|
||||||
|
const preparationTimeInput: HTMLInputElement = fixture.debugElement.query(
|
||||||
|
By.css(`[data-test-role=${inputRole}]`),
|
||||||
|
).nativeElement;
|
||||||
|
preparationTimeInput.value = 'abcd';
|
||||||
|
preparationTimeInput.dispatchEvent(new Event('input'));
|
||||||
|
preparationTimeInput.dispatchEvent(new Event('blur'));
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const errorDebug = fixture.debugElement.query(By.css(`[data-test-role=${errorRole}]`));
|
||||||
|
expect(errorDebug).toBeTruthy();
|
||||||
|
expect(errorDebug.nativeElement.textContent).toContain('Must be a valid number.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('time inputs', () => {
|
||||||
|
testTimeInput('preparation time', 'preparation-time-input', 'preparation-time-error');
|
||||||
|
testTimeInput('cooking time', 'cooking-time-input', 'cooking-time-error');
|
||||||
|
testTimeInput('total time', 'total-time-input', 'total-time-error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,41 +1,253 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
import {
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
afterNextRender,
|
||||||
import { RecipeEditFormSubmitEvent } from '../../../../shared/components/recipe-edit-form/RecipeEditFormSubmitEvent';
|
Component,
|
||||||
import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
|
computed,
|
||||||
import { RecipeDraftViewModel } from '../../../../shared/models/RecipeDraftView.model';
|
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 { MatButton } from '@angular/material/button';
|
||||||
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
|
import { EnterRecipeDataSubmitEvent } from './EnterRecipeDataSubmitEvent';
|
||||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
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 { DatePipe } from '@angular/common';
|
||||||
|
import { ImageSelect } from './image-select/image-select';
|
||||||
|
import { ImageView } from '../../../../shared/models/ImageView.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-enter-recipe-data',
|
selector: 'app-enter-recipe-data',
|
||||||
imports: [
|
imports: [
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
|
MatFormField,
|
||||||
|
MatLabel,
|
||||||
|
MatInput,
|
||||||
MatButton,
|
MatButton,
|
||||||
MatMenuTrigger,
|
|
||||||
FaIconComponent,
|
FaIconComponent,
|
||||||
|
MatMenuTrigger,
|
||||||
MatMenu,
|
MatMenu,
|
||||||
MatMenuItem,
|
MatMenuItem,
|
||||||
RecipeEditForm,
|
MatTable,
|
||||||
|
MatColumnDef,
|
||||||
|
MatHeaderCell,
|
||||||
|
MatHeaderCellDef,
|
||||||
|
MatCell,
|
||||||
|
MatCellDef,
|
||||||
|
MatHeaderRow,
|
||||||
|
MatHeaderRowDef,
|
||||||
|
MatRow,
|
||||||
|
MatRowDef,
|
||||||
|
CdkDropList,
|
||||||
|
CdkDrag,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
|
ImageSelect,
|
||||||
|
MatError,
|
||||||
],
|
],
|
||||||
templateUrl: './enter-recipe-data.html',
|
templateUrl: './enter-recipe-data.html',
|
||||||
styleUrl: './enter-recipe-data.css',
|
styleUrl: './enter-recipe-data.css',
|
||||||
})
|
})
|
||||||
export class EnterRecipeData {
|
export class EnterRecipeData implements OnInit {
|
||||||
public readonly draft = input.required<RecipeDraftViewModel>();
|
public readonly draft = input.required<RecipeDraftViewModel>();
|
||||||
public readonly recipeSubmit = output<RecipeEditFormSubmitEvent>();
|
public readonly submit = output<EnterRecipeDataSubmitEvent>();
|
||||||
public readonly deleteDraft = output<void>();
|
public readonly deleteDraft = output<void>();
|
||||||
|
|
||||||
protected onSubmit(event: RecipeEditFormSubmitEvent): void {
|
protected readonly recipeTextTextarea = viewChild.required<ElementRef<HTMLTextAreaElement>>('recipeTextTextarea');
|
||||||
this.recipeSubmit.emit(event);
|
|
||||||
|
private readonly dialog = inject(MatDialog);
|
||||||
|
|
||||||
|
protected readonly recipeFormGroup = new FormGroup({
|
||||||
|
title: new FormControl('', Validators.required),
|
||||||
|
slug: new FormControl('', Validators.required),
|
||||||
|
preparationTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||||
|
cookingTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||||
|
totalTime: new FormControl('', Validators.pattern(/^\d+$/)),
|
||||||
|
text: new FormControl('', Validators.required),
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly ingredientsTable = viewChild<MatTable<unknown>>('ingredientsTable');
|
||||||
|
protected readonly ingredientModels = signal<IngredientDraftClientModel[]>([]);
|
||||||
|
|
||||||
|
private readonly mainImage = signal<ImageView | null>(null);
|
||||||
|
protected readonly mainImageUsernameFilename = computed(() => {
|
||||||
|
const mainImage = this.mainImage();
|
||||||
|
if (mainImage) {
|
||||||
|
return [mainImage.owner.username, mainImage.filename] as const;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly injector = inject(Injector);
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
const draft = this.draft();
|
||||||
|
this.recipeFormGroup.patchValue({
|
||||||
|
title: draft.title ?? '',
|
||||||
|
slug: draft.slug ?? '',
|
||||||
|
preparationTime: draft.preparationTime?.toString() ?? '',
|
||||||
|
cookingTime: draft.cookingTime?.toString() ?? '',
|
||||||
|
totalTime: draft.totalTime?.toString() ?? '',
|
||||||
|
text: draft.rawText ?? '',
|
||||||
|
});
|
||||||
|
if (draft.ingredients) {
|
||||||
|
this.ingredientModels.set(
|
||||||
|
draft.ingredients.map((ingredient, index) => ({
|
||||||
|
id: index,
|
||||||
|
draft: ingredient,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
runInInjectionContext(this.injector, () => {
|
||||||
|
afterNextRender({
|
||||||
|
mixedReadWrite: () => {
|
||||||
|
this.updateTextareaHeight(this.recipeTextTextarea().nativeElement);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateTextareaHeight(textarea: HTMLTextAreaElement) {
|
||||||
|
const windowScrollX = window.scrollX;
|
||||||
|
const windowScrollY = window.scrollY;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollTo(windowScrollX, windowScrollY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onRecipeTextChange(event: Event): void {
|
||||||
|
this.updateTextareaHeight(event.target as HTMLTextAreaElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addIngredient() {
|
||||||
|
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
||||||
|
IngredientDialog,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
id: this.ingredientModels().length,
|
||||||
|
draft: {
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
dialogRef.afterClosed().subscribe((ingredientModel) => {
|
||||||
|
if (ingredientModel) {
|
||||||
|
this.ingredientModels.update((ingredientModels) => [...ingredientModels, ingredientModel]);
|
||||||
|
this.ingredientsTable()!.renderRows();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected editIngredient(model: IngredientDraftClientModel): void {
|
||||||
|
const dialogRef = this.dialog.open<IngredientDialog, IngredientDraftClientModel, IngredientDraftClientModel>(
|
||||||
|
IngredientDialog,
|
||||||
|
{
|
||||||
|
data: model,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
dialogRef.afterClosed().subscribe((model) => {
|
||||||
|
if (model) {
|
||||||
|
this.ingredientModels.update((models) => {
|
||||||
|
const updated: IngredientDraftClientModel[] = [...models];
|
||||||
|
const target = updated.find((search) => search.id === model.id);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('Ingredient does not exist.');
|
||||||
|
}
|
||||||
|
target.draft = model.draft;
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onIngredientDelete(ingredientModel: IngredientDraftClientModel) {
|
||||||
|
this.ingredientModels.update((ingredientModels) => {
|
||||||
|
const updated = ingredientModels.filter((model) => model.id !== ingredientModel.id);
|
||||||
|
updated.sort((model0, model1) => model0.id - model1.id);
|
||||||
|
updated.forEach((model, index) => {
|
||||||
|
model.id = index;
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onIngredientDrop(event: CdkDragDrop<IngredientDraftClientModel>): void {
|
||||||
|
this.ingredientModels.update((ingredientModels) => {
|
||||||
|
const modelIndex = ingredientModels.findIndex((m) => m.id === event.previousIndex);
|
||||||
|
moveItemInArray(ingredientModels, modelIndex, event.currentIndex);
|
||||||
|
ingredientModels.forEach((model, index) => {
|
||||||
|
model.id = index;
|
||||||
|
});
|
||||||
|
return ingredientModels;
|
||||||
|
});
|
||||||
|
this.ingredientsTable()!.renderRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTime(s?: string | null): number | null {
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
return parseInt(s);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Should not have had a parse error because of form validators: ${e}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onSubmit(event: SubmitEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
const value = this.recipeFormGroup.value;
|
||||||
|
this.submit.emit({
|
||||||
|
title: value.title!,
|
||||||
|
slug: value.slug!,
|
||||||
|
ingredients: this.ingredientModels().map((ingredientModel) => ingredientModel.draft),
|
||||||
|
preparationTime: this.getTime(value.preparationTime),
|
||||||
|
cookingTime: this.getTime(value.cookingTime),
|
||||||
|
totalTime: this.getTime(value.totalTime),
|
||||||
|
mainImage: this.mainImage(),
|
||||||
|
rawText: value.text!,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onDraftDelete(): void {
|
protected onDraftDelete(): void {
|
||||||
this.deleteDraft.emit();
|
this.deleteDraft.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected readonly faEllipsis = faEllipsis;
|
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 { SliceView } from '../models/SliceView.model';
|
||||||
import { WithStringDates } from '../util';
|
import { WithStringDates } from '../util';
|
||||||
import { ImageService } from './ImageService';
|
import { ImageService } from './ImageService';
|
||||||
import { RecipeUpdateBody } from '../bodies/RecipeUpdateBody';
|
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
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>> {
|
public getRecipes(): Observable<SliceView<RecipeInfoView>> {
|
||||||
return this.http.get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes')).pipe(
|
return this.http.get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes')).pipe(
|
||||||
map((sliceView) => ({
|
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 {
|
private getRecipeBaseUrl(recipeView: FullRecipeViewWrapper): string {
|
||||||
return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]);
|
return this.endpointService.getUrl('recipes', [recipeView.recipe.owner.username, recipeView.recipe.slug]);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user