Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse Brault
b97803d31d MME-36 Add paginator to recipes-page. 2026-02-16 18:03:45 -06:00
Jesse Brault
547a23e50a Move recipe edit components to proper location. 2026-02-16 17:21:46 -06:00
20 changed files with 101 additions and 36 deletions

View File

@ -5,4 +5,11 @@
<p>There was an error loading recipes: {{ loadRecipesError() }}</p> <p>There was an error loading recipes: {{ loadRecipesError() }}</p>
} @else { } @else {
<app-recipe-card-grid [recipes]="recipes()"></app-recipe-card-grid> <app-recipe-card-grid [recipes]="recipes()"></app-recipe-card-grid>
<mat-paginator
[length]="recipeCount()"
[pageIndex]="currentPage()"
[pageSize]="pageSize()"
[pageSizeOptions]="[5, 10, 25, 50]"
(page)="onPage($event)"
></mat-paginator>
} }

View File

@ -3,10 +3,12 @@ import { RecipeService } from '../../shared/services/RecipeService';
import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid'; import { RecipeCardGrid } from '../../shared/components/recipe-card-grid/recipe-card-grid';
import { RecipeInfoView } from '../../shared/models/Recipe.model'; import { RecipeInfoView } from '../../shared/models/Recipe.model';
import { Spinner } from '../../shared/components/spinner/spinner'; import { Spinner } from '../../shared/components/spinner/spinner';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { combineLatest } from 'rxjs';
@Component({ @Component({
selector: 'app-recipes-page', selector: 'app-recipes-page',
imports: [RecipeCardGrid, Spinner], imports: [RecipeCardGrid, Spinner, MatPaginator],
templateUrl: './recipes-page.html', templateUrl: './recipes-page.html',
styleUrl: './recipes-page.css', styleUrl: './recipes-page.css',
}) })
@ -17,12 +19,27 @@ export class RecipesPage implements OnInit {
protected readonly loadRecipesError = signal<Error | null>(null); protected readonly loadRecipesError = signal<Error | null>(null);
protected readonly recipes = signal<RecipeInfoView[]>([]); protected readonly recipes = signal<RecipeInfoView[]>([]);
protected readonly recipeCount = signal(50);
protected readonly currentPage = signal(0);
protected readonly pageSize = signal(10);
public ngOnInit(): void { public ngOnInit(): void {
this.loadRecipes();
}
private loadRecipes() {
this.loadingRecipes.set(true); this.loadingRecipes.set(true);
this.recipeService.getRecipes().subscribe({ combineLatest([
next: (sliceView) => { this.recipeService.getRecipes({
page: this.currentPage(),
size: this.pageSize(),
}),
this.recipeService.getRecipeCount(),
]).subscribe({
next: ([sliceView, count]) => {
this.loadingRecipes.set(false); this.loadingRecipes.set(false);
this.recipes.set(sliceView.content); this.recipes.set(sliceView.content);
this.recipeCount.set(count);
}, },
error: (e) => { error: (e) => {
this.loadingRecipes.set(false); this.loadingRecipes.set(false);
@ -30,4 +47,24 @@ export class RecipesPage implements OnInit {
}, },
}); });
} }
protected onPage(pageEvent: PageEvent): void {
// chart
// | size-change | size-same
// page-change | reload | reload
// page-same | reload | nothing
if (pageEvent.pageSize !== this.pageSize() || pageEvent.pageIndex !== this.currentPage()) {
this.pageSize.set(pageEvent.pageSize);
this.currentPage.update((old) => {
if (pageEvent.pageIndex < old) {
return Math.max(old - 1, 0);
} else if (pageEvent.pageIndex > old) {
return (this.currentPage() + 1) * this.pageSize() <= this.recipeCount() ? old + 1 : old;
} else {
return old;
}
});
this.loadRecipes();
}
}
} }

View File

@ -3,8 +3,8 @@ import { By } from '@angular/platform-browser';
import { EditImageDialog } from './edit-image-dialog'; import { EditImageDialog } from './edit-image-dialog';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
import { ImageService } from '../../../../../../shared/services/ImageService'; import { ImageService } from '../../../../services/ImageService';
import { ImageView } from '../../../../../../shared/models/ImageView.model'; import { ImageView } from '../../../../models/ImageView.model';
import { of } from 'rxjs'; import { of } from 'rxjs';
describe('EditImageDialog', () => { describe('EditImageDialog', () => {

View File

@ -1,13 +1,13 @@
import { Component, inject, OnInit, signal } from '@angular/core'; import { Component, inject, OnInit, signal } from '@angular/core';
import { DialogContainer } from '../../../../../../shared/components/dialog-container/dialog-container'; import { DialogContainer } from '../../../dialog-container/dialog-container';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ImageView } from '../../../../../../shared/models/ImageView.model'; import { ImageView } from '../../../../models/ImageView.model';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { ImageService } from '../../../../../../shared/services/ImageService'; import { ImageService } from '../../../../services/ImageService';
import { notNullOrUndefined } from '../../../../../../shared/util'; import { notNullOrUndefined } from '../../../../util';
import { Spinner } from '../../../../../../shared/components/spinner/spinner'; import { Spinner } from '../../../spinner/spinner';
@Component({ @Component({
selector: 'app-edit-image-dialog', selector: 'app-edit-image-dialog',

View File

@ -1,10 +1,10 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImageSelect } from './image-select'; import { ImageSelect } from './image-select';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
import { ImageService } from '../../../../../shared/services/ImageService'; import { ImageService } from '../../../services/ImageService';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { SliceView, SliceViewMeta } from '../../../../../shared/models/SliceView.model'; import { SliceView, SliceViewMeta } from '../../../models/SliceView.model';
import { ImageViewWithBlobUrl } from '../../../../../shared/client-models/ImageViewWithBlobUrl'; import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl';
describe('ImageSelect', () => { describe('ImageSelect', () => {
let component: ImageSelect; let component: ImageSelect;

View File

@ -1,8 +1,8 @@
import { Component, inject, input, OnInit, output, signal } from '@angular/core'; import { Component, inject, input, OnInit, output, signal } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { ImageService } from '../../../../../shared/services/ImageService'; import { ImageService } from '../../../services/ImageService';
import { Spinner } from '../../../../../shared/components/spinner/spinner'; import { Spinner } from '../../spinner/spinner';
import { ImageView } from '../../../../../shared/models/ImageView.model'; import { ImageView } from '../../../models/ImageView.model';
import { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card'; import { MatCard, MatCardActions, MatCardContent, MatCardImage } from '@angular/material/card';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
@ -11,8 +11,8 @@ import { faEllipsis } from '@fortawesome/free-solid-svg-icons';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { EditImageDialog } from './edit-image-dialog/edit-image-dialog'; import { EditImageDialog } from './edit-image-dialog/edit-image-dialog';
import { SliceView } from '../../../../../shared/models/SliceView.model'; import { SliceView } from '../../../models/SliceView.model';
import { ImageViewWithBlobUrl } from '../../../../../shared/client-models/ImageViewWithBlobUrl'; import { ImageViewWithBlobUrl } from '../../../client-models/ImageViewWithBlobUrl';
@Component({ @Component({
selector: 'app-image-select', selector: 'app-image-select',

View File

@ -1,16 +1,16 @@
import { Component, computed, inject, signal } from '@angular/core'; import { Component, computed, inject, signal } from '@angular/core';
import { ImageService } from '../../../../../shared/services/ImageService'; import { ImageService } from '../../../services/ImageService';
import { FileUpload } from '../../../../../shared/components/file-upload/file-upload'; import { FileUpload } from '../../file-upload/file-upload';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { faFileImage } from '@fortawesome/free-solid-svg-icons'; import { faFileImage } from '@fortawesome/free-solid-svg-icons';
import { FileUploadEvent } from '../../../../../shared/components/file-upload/FileUploadEvent'; import { FileUploadEvent } from '../../file-upload/FileUploadEvent';
import { Spinner } from '../../../../../shared/components/spinner/spinner'; import { Spinner } from '../../spinner/spinner';
import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input'; import { MatError, MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
import { ImageDoesNotExistValidator } from '../../../../../shared/validators/image-does-not-exist-validator'; import { ImageDoesNotExistValidator } from '../../../validators/image-does-not-exist-validator';
import { DialogContainer } from '../../../../../shared/components/dialog-container/dialog-container'; import { DialogContainer } from '../../dialog-container/dialog-container';
@Component({ @Component({
selector: 'app-image-upload-dialog', selector: 'app-image-upload-dialog',

View File

@ -1,10 +1,10 @@
import { Component, inject, OnInit } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { DialogContainer } from '../../../../../shared/components/dialog-container/dialog-container'; import { DialogContainer } from '../../dialog-container/dialog-container';
import { MatFormField, MatInput, MatLabel } from '@angular/material/input'; import { MatFormField, MatInput, MatLabel } from '@angular/material/input';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { IngredientDraftClientModel } from '../../../../../shared/client-models/IngredientDraftClientModel'; import { IngredientDraftClientModel } from '../../../client-models/IngredientDraftClientModel';
@Component({ @Component({
selector: 'app-ingredient-dialog', selector: 'app-ingredient-dialog',

View File

@ -14,7 +14,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ImageSelect } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/image-select/image-select'; import { ImageSelect } from './image-select/image-select';
import { MatButton } from '@angular/material/button'; import { MatButton } from '@angular/material/button';
import { import {
MatCell, MatCell,
@ -34,11 +34,11 @@ import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angula
import { faBars, faEllipsis } from '@fortawesome/free-solid-svg-icons'; import { faBars, faEllipsis } from '@fortawesome/free-solid-svg-icons';
import { IngredientDraftClientModel } from '../../client-models/IngredientDraftClientModel'; import { IngredientDraftClientModel } from '../../client-models/IngredientDraftClientModel';
import { ImageView } from '../../models/ImageView.model'; import { ImageView } from '../../models/ImageView.model';
import { IngredientDialog } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/ingredient-dialog/ingredient-dialog'; import { IngredientDialog } from './ingredient-dialog/ingredient-dialog';
import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model'; import { RecipeDraftViewModel } from '../../models/RecipeDraftView.model';
import { RecipeEditFormSubmitEvent } from './RecipeEditFormSubmitEvent'; import { RecipeEditFormSubmitEvent } from './RecipeEditFormSubmitEvent';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ImageUploadDialog } from '../../../pages/recipe-upload-page/steps/enter-recipe-data/image-upload-dialog/image-upload-dialog'; import { ImageUploadDialog } from './image-upload-dialog/image-upload-dialog';
import { FullRecipeView } from '../../models/Recipe.model'; import { FullRecipeView } from '../../models/Recipe.model';
@Component({ @Component({

View File

@ -16,6 +16,17 @@ import { RecipeUpdateBody } from '../bodies/RecipeUpdateBody';
providedIn: 'root', providedIn: 'root',
}) })
export class RecipeService { export class RecipeService {
public static readonly RecipeProperties = [
'id',
'created',
'modified',
'title',
'slug',
'preparationTime',
'cookingTime',
'totalTime',
] as const;
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly queryClient = inject(QueryClient); private readonly queryClient = inject(QueryClient);
@ -46,13 +57,23 @@ export class RecipeService {
}; };
} }
public getRecipes(): Observable<SliceView<RecipeInfoView>> { public getRecipes(
return this.http.get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes')).pipe( queryParams?: QueryParams<typeof RecipeService.RecipeProperties>,
map((sliceView) => ({ ): Observable<SliceView<RecipeInfoView>> {
...sliceView, return this.http
content: sliceView.content.map((withStringDates) => this.hydrateRecipeInfoView(withStringDates)), .get<SliceView<WithStringDates<RecipeInfoView>>>(this.endpointService.getUrl('recipes', [], queryParams))
})), .pipe(
); map((sliceView) => ({
...sliceView,
content: sliceView.content.map((withStringDates) => this.hydrateRecipeInfoView(withStringDates)),
})),
);
}
public getRecipeCount(): Observable<number> {
return this.http
.get<{ count: number }>(this.endpointService.getUrl('recipes', ['meta', 'count']))
.pipe(map((res) => res.count));
} }
public getRecipeView(username: string, slug: string): Promise<FullRecipeViewWrapper> { public getRecipeView(username: string, slug: string): Promise<FullRecipeViewWrapper> {