Adapt to new recipe filter and fix create recipe

This commit is contained in:
araemer 2026-02-27 20:37:07 +01:00
parent 709cb23f3d
commit 79d62b140e
7 changed files with 82 additions and 36 deletions

View file

@ -0,0 +1,11 @@
import { AbstractDto } from "./AbstractDto.js";
/**
* DTO describing the essential header data of a recipe
* Used to populate lists
*/
export class CompactRecipeDto extends AbstractDto {
title!: string;
// @todo add resource and rating here once implemented!
}

View file

@ -0,0 +1,13 @@
/**
* Request wrapper for searching recipes based on a filter
*/
export class CompactRecipeFilterRequest {
/**
* Search string applied to the recipe title
*/
searchString?: string;
/**
* List of tags that must be applied to the recipe
*/
tagIdList?: string[];
}

View file

@ -0,0 +1,8 @@
import {CompactRecipeDto} from "./CompactRecipeDto.js";
/**
* Filter response containing a list of all recipes matching the search
*/
export class CompactRecipeFilterResponse {
compactRecipeList! : CompactRecipeDto[];
}

View file

@ -1,22 +1,32 @@
import type {RecipeModel} from "../../models/RecipeModel"
import {apiClient} from "../apiClient.ts"; import {apiClient} from "../apiClient.ts";
import type {CompactRecipeDto} from "../dtos/CompactRecipeDto.ts";
import type {CompactRecipeFilterRequest} from "../dtos/CompactRecipeFilterRequest.ts";
import type {CompactRecipeFilterResponse} from "../dtos/CompactRecipeFilterResponse.ts";
/** /**
* URL for handling recipes header data * URL for handling recipe header data
*/ */
const RECIPE_URL = "/compact-recipe" const COMPACT_RECIPE_URL = "/compact-recipe"
/** /**
* Load list of all recipes * Loads recipe header data for all recipes matching the given filter criteria.
* @param searchString Search string for filtering recipeList * If neither a search string nor tag IDs are provided, all recipes are returned.
* @returns Array of recipe *
* @param searchString Optional title search string
* @param tagIdList Optional list of tag IDs the recipe must have all of
* @returns Filtered list of compact recipe DTOs
*/ */
export async function fetchRecipeList(searchString: string): Promise<RecipeModel[]> { export async function fetchRecipeList(
let url: string = RECIPE_URL; // add an s to the base URL as we want to load a list searchString?: string,
// if there's a search string add it as query parameter tagIdList?: string[]
if (searchString && searchString !== "") { ): Promise<CompactRecipeDto[]> {
url += "?search=" + searchString; const request: CompactRecipeFilterRequest = {
} searchString: searchString && searchString.length > 0 ? searchString : undefined,
return apiClient.get(url); tagIdList: tagIdList && tagIdList.length > 0 ? tagIdList : undefined,
};
const response = await apiClient.post<CompactRecipeFilterResponse>(
`${COMPACT_RECIPE_URL}/list-by-filter`,
request
);
return response.compactRecipeList;
} }

View file

@ -30,6 +30,7 @@ export default function RecipeEditPage() {
title: "", title: "",
ingredientGroupList: [], ingredientGroupList: [],
instructionStepList: [], instructionStepList: [],
tagList: [],
servings: { servings: {
amount: 1, amount: 1,
unit: "" unit: ""

View file

@ -1,39 +1,41 @@
import {useEffect, useState} from "react" import {useEffect, useState} from "react"
import RecipeListItem from "./RecipeListItem" import RecipeListItem from "./RecipeListItem"
import type {RecipeModel} from "../../models/RecipeModel" import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts"
import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.ts" import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.ts"
import {useNavigate} from "react-router-dom" import {useNavigate} from "react-router-dom"
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes" import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
import RecipeListToolbar from "./RecipeListToolbar" import RecipeListToolbar from "./RecipeListToolbar"
import StickyHeader from "../basics/StickyHeader.tsx"; import StickyHeader from "../basics/StickyHeader.tsx"
import PageContainer from "../basics/PageContainer.tsx"; import PageContainer from "../basics/PageContainer.tsx"
import PageContentLayout from "../basics/PageContentLayout.tsx"; import PageContentLayout from "../basics/PageContentLayout.tsx"
/** Debounce delay in ms — list only reloads once the user stops typing */
const SEARCH_DEBOUNCE_MS = 200
/** /**
* Displays a list of recipes in a sidebar layout. * Displays a list of recipes in a sidebar layout.
* Each recipe link fills the available width. * Each recipe link fills the available width.
*/ */
export default function RecipeListPage() { export default function RecipeListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [recipeList, setRecipeList] = useState<RecipeModel[] | null>(null) const [recipeList, setRecipeList] = useState<CompactRecipeDto[] | null>(null)
const [searchString, setSearchString] = useState<string>("") const [searchString, setSearchString] = useState<string>("")
// load recipes once on render and whenever search string changes const [tagIdList, setTagIdList] = useState<string[]>([])
// @todo add delay. Only reload list if the search string hasn't changed for ~200 ms
// Reload list whenever search string or tag filter changes, debounced to
// avoid firing on every keystroke
useEffect(() => { useEffect(() => {
console.log("loading recipe list with searchString", searchString) const timeout = setTimeout(async () => {
const loadRecipeList = async () => {
try { try {
// Fetch recipe list const data = await fetchRecipeList(searchString, tagIdList)
const data = await fetchRecipeList(searchString)
// @todo add and use compact recipe mapper
setRecipeList(data) setRecipeList(data)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
} }, SEARCH_DEBOUNCE_MS)
loadRecipeList()
}, [searchString]) return () => clearTimeout(timeout)
}, [searchString, tagIdList])
const handleAdd = () => { const handleAdd = () => {
navigate(getRecipeAddUrl()) navigate(getRecipeAddUrl())
@ -42,8 +44,9 @@ export default function RecipeListPage() {
if (!recipeList) { if (!recipeList) {
return <div>Loading!</div> return <div>Loading!</div>
} }
return ( return (
/*Container spanning entire screen used to center content horizontally */ /* Container spanning entire screen used to center content horizontally */
<PageContainer> <PageContainer>
{/* Container defining the maximum width of the content */} {/* Container defining the maximum width of the content */}
<PageContentLayout> <PageContentLayout>
@ -56,7 +59,7 @@ export default function RecipeListPage() {
numberOfRecipes={recipeList.length} numberOfRecipes={recipeList.length}
/> />
</StickyHeader> </StickyHeader>
{/*Content - List of recipe cards */} {/* Content - List of recipe cards */}
<div className="w-full pt-4"> <div className="w-full pt-4">
<div <div
className="grid gap-6 grid-cols-[repeat(auto-fit,minmax(220px,auto))] max-w-6xl mx-auto justify-center"> className="grid gap-6 grid-cols-[repeat(auto-fit,minmax(220px,auto))] max-w-6xl mx-auto justify-center">
@ -64,7 +67,7 @@ export default function RecipeListPage() {
<RecipeListItem <RecipeListItem
key={recipe.id} key={recipe.id}
title={recipe.title} title={recipe.title}
targetPath={recipe.id !== undefined ? getRecipeDetailUrl(recipe.id) : getRecipeListUrl()} // @todo proper error handling targetPath={recipe.id !== undefined ? getRecipeDetailUrl(recipe.id) : getRecipeListUrl()}
/> />
))} ))}
</div> </div>

View file

@ -77,7 +77,7 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
const tagDtos = model.tagList.map(mapTagModelToDto) const tagDtos = model.tagList.map(mapTagModelToDto)
return { return {
id: model.id, id: model.id ? model.id : undefined,
title: model.title, title: model.title,
amount: model.servings.amount, amount: model.servings.amount,
amountDescription: model.servings.unit, amountDescription: model.servings.unit,