diff --git a/frontend/src/api/dtos/AbstractDto.ts b/frontend/src/api/dtos/AbstractDto.ts new file mode 100644 index 0000000..be585a9 --- /dev/null +++ b/frontend/src/api/dtos/AbstractDto.ts @@ -0,0 +1,5 @@ +export abstract class AbstractDto { + id?: string; + createdAt?: Date; + updatedAt?: Date; +} \ No newline at end of file diff --git a/frontend/src/api/dtos/RecipeDto.ts b/frontend/src/api/dtos/RecipeDto.ts index b8d757f..bc5bedc 100644 --- a/frontend/src/api/dtos/RecipeDto.ts +++ b/frontend/src/api/dtos/RecipeDto.ts @@ -1,5 +1,5 @@ -import { AbstractDto } from "./AbstractDto.js"; +import { AbstractDto } from "./AbstractDto.ts"; import { RecipeIngredientGroupDto } from "./RecipeIngredientGroupDto.js"; import { RecipeInstructionStepDto } from "./RecipeInstructionStepDto.js"; /** diff --git a/frontend/src/api/dtos/RecipeIngredientDto.ts b/frontend/src/api/dtos/RecipeIngredientDto.ts index 3d7c91f..c88d71b 100644 --- a/frontend/src/api/dtos/RecipeIngredientDto.ts +++ b/frontend/src/api/dtos/RecipeIngredientDto.ts @@ -1,4 +1,4 @@ -import { AbstractDto } from "./AbstractDto.js"; +import { AbstractDto } from "./AbstractDto.ts"; export class RecipeIngredientDto extends AbstractDto{ name!: string; diff --git a/frontend/src/api/dtos/RecipeIngredientGroupDto.ts b/frontend/src/api/dtos/RecipeIngredientGroupDto.ts index 324d3c5..3b1f9eb 100644 --- a/frontend/src/api/dtos/RecipeIngredientGroupDto.ts +++ b/frontend/src/api/dtos/RecipeIngredientGroupDto.ts @@ -1,4 +1,4 @@ -import { AbstractDto } from "./AbstractDto.js"; +import { AbstractDto } from "./AbstractDto.ts"; import { RecipeIngredientDto } from "./RecipeIngredientDto.js"; export class RecipeIngredientGroupDto extends AbstractDto{ diff --git a/frontend/src/api/dtos/RecipeInstructionStepDto.ts b/frontend/src/api/dtos/RecipeInstructionStepDto.ts index 362e282..b9b902e 100644 --- a/frontend/src/api/dtos/RecipeInstructionStepDto.ts +++ b/frontend/src/api/dtos/RecipeInstructionStepDto.ts @@ -1,5 +1,5 @@ import { UUID } from "crypto"; -import { AbstractDto } from "./AbstractDto.js"; +import { AbstractDto } from "./AbstractDto.ts"; export class RecipeInstructionStepDto extends AbstractDto{ text!: string; diff --git a/frontend/src/api/dtos/UserDto.ts b/frontend/src/api/dtos/UserDto.ts index 56fc6c2..972cad6 100644 --- a/frontend/src/api/dtos/UserDto.ts +++ b/frontend/src/api/dtos/UserDto.ts @@ -1,4 +1,4 @@ -import { AbstractDto } from "./AbstractDto.js"; +import { AbstractDto } from "./AbstractDto.ts"; export class UserDto extends AbstractDto { firstName?: string; diff --git a/frontend/src/api/points/AuthPoint.ts b/frontend/src/api/points/AuthPoint.ts index 493f34c..bf34abd 100644 --- a/frontend/src/api/points/AuthPoint.ts +++ b/frontend/src/api/points/AuthPoint.ts @@ -1,7 +1,6 @@ -import type { Recipe } from "../../types/recipe" import type { LoginRequestDto } from "../dtos/LoginRequestDto"; import type { LoginResponseDto } from "../dtos/LoginResponseDto"; -import { get, postJson, putJson } from "../utils/requests"; +import { postJson } from "../utils/requests"; /** diff --git a/frontend/src/api/points/CompactRecipePoint.ts b/frontend/src/api/points/CompactRecipePoint.ts index 6579e4d..7855594 100644 --- a/frontend/src/api/points/CompactRecipePoint.ts +++ b/frontend/src/api/points/CompactRecipePoint.ts @@ -1,5 +1,5 @@ -import type { Recipe } from "../../types/recipe" -import { get, postJson, putJson } from "../utils/requests"; +import type { RecipeModel } from "../../models/RecipeModel" +import { get } from "../utils/requests"; /** @@ -18,7 +18,7 @@ const RECIPE_URL = `${BASE_URL}/compact-recipe` * @param searchString Search string for filtering recipeList * @returns Array of recipe */ -export async function fetchRecipeList(searchString : string): Promise { +export async function fetchRecipeList(searchString : string): Promise { let url : string = RECIPE_URL; // if there's a search string add it as query parameter if(searchString && searchString !== ""){ diff --git a/frontend/src/api/points/RecipePoint.ts b/frontend/src/api/points/RecipePoint.ts index 73bf935..b437d0f 100644 --- a/frontend/src/api/points/RecipePoint.ts +++ b/frontend/src/api/points/RecipePoint.ts @@ -1,4 +1,4 @@ -import type { Recipe } from "../../types/recipe" +import type { RecipeDto } from "../dtos/RecipeDto"; import { get, postJson, putJson } from "../utils/requests"; @@ -18,7 +18,7 @@ const RECIPE_URL = `${BASE_URL}/recipe` * @param id ID of the recipe to load * @returns A single recipe */ -export async function fetchRecipe(id: string): Promise { +export async function fetchRecipe(id: string): Promise { const res = await get(`${RECIPE_URL}/${id}`) return res.json() } @@ -28,7 +28,7 @@ export async function fetchRecipe(id: string): Promise { * @param recipe Recipe to create * @returns Saved recipe */ -export async function createRecipe(recipe: Recipe): Promise { +export async function createRecipe(recipe: RecipeDto): Promise { const res = await postJson(RECIPE_URL, JSON.stringify(recipe)); return res.json(); } @@ -38,7 +38,7 @@ export async function createRecipe(recipe: Recipe): Promise { * @param recipe Recipe to save. This recipe must have an ID! * @returns Saved recipe */ -export async function updateRecipe(recipe: Recipe): Promise { +export async function updateRecipe(recipe: RecipeDto): Promise { const res = await putJson(`${RECIPE_URL}/${recipe.id}`, JSON.stringify(recipe)); return res.json(); } diff --git a/frontend/src/api/utils/headers.ts b/frontend/src/api/utils/headers.ts index 2ca7bfa..44eaa23 100644 --- a/frontend/src/api/utils/headers.ts +++ b/frontend/src/api/utils/headers.ts @@ -1,6 +1,4 @@ -const BASE_URL = import.meta.env.VITE_API_BASE; - export function createBasicHeader() : Headers { const headers = new Headers(); //headers.set('Access-Control-Allow-Origin', '*'); diff --git a/frontend/src/components/recipes/IngredientGroupListEditor.tsx b/frontend/src/components/recipes/IngredientGroupListEditor.tsx index 604ccb0..778dd1f 100644 --- a/frontend/src/components/recipes/IngredientGroupListEditor.tsx +++ b/frontend/src/components/recipes/IngredientGroupListEditor.tsx @@ -2,25 +2,25 @@ * Editor for ingredient groups */ -import type { Ingredient } from "../../types/ingredient" -import type { IngredientGroup } from "../../types/ingredientGroup" +import type { IngredientModel } from "../../models/IngredientModel" +import type { IngredientGroupModel } from "../../models/IngredientGroupModel" import Button, { ButtonType } from "../basics/Button" import SvgIcon, { Icon } from "../basics/SvgIcon" import { IngredientListEditor } from "./IngredientListEditor" type IngredientGroupListEditorProps = { - ingredientGroupList: IngredientGroup[] - onChange: (ingredientGroupList: IngredientGroup[]) => void + ingredientGroupList: IngredientGroupModel[] + onChange: (ingredientGroupList: IngredientGroupModel[]) => void } export function IngredientGroupListEditor({ ingredientGroupList, onChange }: IngredientGroupListEditorProps) { - const handleUpdate = (index: number, field: keyof IngredientGroup, value: string|Ingredient[] ) => { + const handleUpdate = (index: number, field: keyof IngredientGroupModel, value: string|IngredientModel[] ) => { const updated = ingredientGroupList.map((ingGrp, i) => i === index ? { ...ingGrp, [field]: value} : ingGrp ) onChange(updated) } - const updateIngredientList = (index: number, ingredientList: Ingredient[]) => { + const updateIngredientList = (index: number, ingredientList: IngredientModel[]) => { handleUpdate(index, "ingredientList", ingredientList) } @@ -33,7 +33,7 @@ export function IngredientGroupListEditor({ ingredientGroupList, onChange }: Ing } return (
- {/* remove bottom margin from this headingas the group card has a top padding */} + {/* remove bottom margin from this headings the group card has a top padding */}

Ingredient Groups

{ingredientGroupList.map((ingGrp, index) => (
diff --git a/frontend/src/components/recipes/IngredientListEditor.tsx b/frontend/src/components/recipes/IngredientListEditor.tsx index 1b10eb8..19c3d65 100644 --- a/frontend/src/components/recipes/IngredientListEditor.tsx +++ b/frontend/src/components/recipes/IngredientListEditor.tsx @@ -1,4 +1,4 @@ -import type { Ingredient } from "../../types/ingredient" +import type { IngredientModel } from "../../models/IngredientModel" import Button, { ButtonType } from "../basics/Button" import SvgIcon, { Icon } from "../basics/SvgIcon" @@ -7,12 +7,12 @@ import SvgIcon, { Icon } from "../basics/SvgIcon" * Ingredients can be edited, added and removed */ type IngredientListEditorProps = { - ingredients: Ingredient[] - onChange: (ingredients: Ingredient[]) => void + ingredients: IngredientModel[] + onChange: (ingredients: IngredientModel[]) => void } export function IngredientListEditor({ ingredients, onChange }: IngredientListEditorProps) { - const handleUpdate = (index: number, field: keyof Ingredient, value: string | number) => { + const handleUpdate = (index: number, field: keyof IngredientModel, value: string | number) => { const updated = ingredients.map((ing, i) => i === index ? { ...ing, [field]: field === "amount" ? Number(value) : value } : ing ) diff --git a/frontend/src/components/recipes/RecipeDetailPage.tsx b/frontend/src/components/recipes/RecipeDetailPage.tsx index 87bb183..aebbc66 100644 --- a/frontend/src/components/recipes/RecipeDetailPage.tsx +++ b/frontend/src/components/recipes/RecipeDetailPage.tsx @@ -1,9 +1,10 @@ import { useParams } from "react-router-dom" -import type { Recipe } from "../../types/recipe" +import type { RecipeModel } from "../../models/RecipeModel" import { useEffect, useState } from "react" import { fetchRecipe } from "../../api/points/RecipePoint" import { getRecipeEditUrl, getRecipeListUrl } from "../../routes" import ButtonLink from "../basics/ButtonLink" +import { mapRecipeDtoToModel } from "../../mappers/recipeMapper" /** @@ -14,9 +15,9 @@ export default function RecipeDetailPage() { // Extract recipe ID from route params const { id } = useParams<{ id: string }>() // the recipe loaded from the backend, don't change this! it's required for scaling - const [recipe, setRecipe] = useState(null) + const [recipe, setRecipe] = useState(null) // Working copy for re-calculating ingredients - const [recipeWorkingCopy, setRecipeWorkingCopy] = useState(null) + const [recipeWorkingCopy, setRecipeWorkingCopy] = useState(null) // load recipe data whenever id changes useEffect(() => { const loadRecipe = async () => { @@ -25,7 +26,10 @@ export default function RecipeDetailPage() { // Fetch recipe data when editing an existing one console.log("loading recipe with id", id) const data = await fetchRecipe(id) - setRecipe(data) + if(data.id != id){ + throw new Error("Id mismatch when loading recipes: " + id + " requested and " + data.id + " received!"); + } + setRecipe(mapRecipeDtoToModel(data)) } catch (err) { console.error(err) } @@ -125,7 +129,7 @@ export default function RecipeDetailPage() { {/* Action buttons */}
diff --git a/frontend/src/components/recipes/RecipeEditPage.tsx b/frontend/src/components/recipes/RecipeEditPage.tsx index a06243d..5660249 100644 --- a/frontend/src/components/recipes/RecipeEditPage.tsx +++ b/frontend/src/components/recipes/RecipeEditPage.tsx @@ -1,14 +1,16 @@ import { useParams, useNavigate } from "react-router-dom" import { useEffect, useState } from "react" -import type { Recipe } from "../../types/recipe" +import type { RecipeModel } from "../../models/RecipeModel" import RecipeEditor from "./RecipeEditor" import { fetchRecipe, createRecipe, updateRecipe } from "../../api/points/RecipePoint" import { getRecipeDetailUrl, getRecipeListUrl } from "../../routes" +import { mapRecipeDtoToModel, mapRecipeModelToDto } from "../../mappers/recipeMapper" +import type { RecipeDto } from "../../api/dtos/RecipeDto" export default function RecipeEditPage() { // Extract recipe ID from route params const { id } = useParams<{ id: string }>() - const [recipe, setRecipe] = useState(null) + const [recipe, setRecipe] = useState(null) const navigate = useNavigate() useEffect(() => { @@ -16,8 +18,8 @@ export default function RecipeEditPage() { if (id) { try { // Fetch recipe data when editing an existing one - const data = await fetchRecipe(id) - setRecipe(data) + const data : RecipeDto = await fetchRecipe(id); + setRecipe(mapRecipeDtoToModel(data)); } catch (err) { console.error(err) } @@ -40,12 +42,13 @@ export default function RecipeEditPage() { loadRecipe() }, [id]) - const handleSave = async (updated: Recipe) => { + const handleSave = async (updated: RecipeModel) => { try { + const dto = mapRecipeModelToDto(updated); if (updated.id) { - await updateRecipe(updated) + await updateRecipe(dto) } else { - await createRecipe(updated) + await createRecipe(dto) } navigateBack(); } catch (err) { diff --git a/frontend/src/components/recipes/RecipeEditor.tsx b/frontend/src/components/recipes/RecipeEditor.tsx index 8b0f564..e4f1289 100644 --- a/frontend/src/components/recipes/RecipeEditor.tsx +++ b/frontend/src/components/recipes/RecipeEditor.tsx @@ -1,12 +1,12 @@ import { useState } from "react" -import type { Recipe } from "../../types/recipe" -import type { IngredientGroup } from "../../types/ingredientGroup" +import type { RecipeModel } from "../../models/RecipeModel" +import type { IngredientGroupModel } from "../../models/IngredientGroupModel" import { IngredientGroupListEditor } from "./IngredientGroupListEditor" import Button, { ButtonType } from "../basics/Button" type RecipeEditorProps = { - recipe: Recipe - onSave: (recipe: Recipe) => void + recipe: RecipeModel + onSave: (recipe: RecipeModel) => void onCancel: () => void } @@ -17,7 +17,7 @@ type RecipeEditorProps = { */ export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorProps) { /** draft of the new recipe */ - const [draft, setDraft] = useState(recipe) + const [draft, setDraft] = useState(recipe) /** Error list */ const [errors, setErrors] = useState<{ title?: boolean; ingredients?: boolean }>({}) @@ -25,7 +25,7 @@ export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorP * Update ingredients * @param ingredients new ingredients */ - const updateIngredientGroupList = (ingredientGroupList: IngredientGroup[]) => { + const updateIngredientGroupList = (ingredientGroupList: IngredientGroupModel[]) => { setDraft({ ...draft, ingredientGroupList }) } /** @@ -63,7 +63,7 @@ export default function RecipeEditor({ recipe, onSave, onCancel }: RecipeEditorP return Object.keys(newErrors).length === 0 } /** Handles saving and ensures that the draft is only saved if valid */ - const handleSave = (draft: Recipe) => { + const handleSave = (draft: RecipeModel) => { if (validate()) { onSave(draft) } diff --git a/frontend/src/components/recipes/RecipeListPage.tsx b/frontend/src/components/recipes/RecipeListPage.tsx index 5b2fb80..591b57a 100644 --- a/frontend/src/components/recipes/RecipeListPage.tsx +++ b/frontend/src/components/recipes/RecipeListPage.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react" import RecipeListItem from "./RecipeListItem" -import type { Recipe } from "../../types/recipe" +import type { RecipeModel } from "../../models/RecipeModel" import { fetchRecipeList } from "../../api/points/CompactRecipePoint" import { useNavigate } from "react-router-dom" -import { getRecipeAddUrl, getRecipeDetailUrl } from "../../routes" +import { getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl } from "../../routes" import RecipeListToolbar from "./RecipeListToolbar" /** @@ -13,7 +13,7 @@ import RecipeListToolbar from "./RecipeListToolbar" export default function RecipeListPage() { const navigate = useNavigate() - const [recipeList, setRecipeList] = useState(null) + const [recipeList, setRecipeList] = useState(null) const [searchString, setSearchString] = useState("") // load recipes once on render and whenever search string changes // @todo add delay. Only reload list if the search string hasn't changed for ~200 ms @@ -23,6 +23,7 @@ export default function RecipeListPage() { try { // Fetch recipe list const data = await fetchRecipeList(searchString) + // @todo add and use compact recipe mapper setRecipeList(data) } catch (err) { console.error(err) @@ -57,7 +58,7 @@ export default function RecipeListPage() { ))}
diff --git a/frontend/src/mappers/recipeMapper.ts b/frontend/src/mappers/recipeMapper.ts new file mode 100644 index 0000000..80945d4 --- /dev/null +++ b/frontend/src/mappers/recipeMapper.ts @@ -0,0 +1,91 @@ +import type { RecipeModel } from "../models/RecipeModel" +import type { RecipeDto } from "../api/dtos/RecipeDto" +import type { RecipeIngredientGroupDto } from "../api/dtos/RecipeIngredientGroupDto" + +/** + * Maps a RecipeDto (as returned by the backend) to the Recipe model + * used in the frontend application. + */ +export function mapRecipeDtoToModel(dto: RecipeDto): RecipeModel { + return { + id: dto.id, + title: dto.title, + + servings: { + amount: dto.amount ?? 1, + unit: dto.amountDescription ?? "", + }, + // @todo implement steps in frontend + // join all instruction step texts into a single string for display + instructions: dto.instructions + .sort((a, b) => a.sortOrder - b.sortOrder) // ensure correct order + .map(step => step.text) + .join("\n"), + + ingredientGroupList: dto.ingredientGroups + .sort((a, b) => a.sortOrder - b.sortOrder) // ensure groups are ordered + .map(group => ({ + id: group.id, + title: group.title, + ingredientList: group.ingredients + .sort((a, b) => a.sortOrder - b.sortOrder) // ensure ingredients are ordered + .map(ing => ({ + id: ing.id, + name: ing.name ?? "", // @todo ensure that name and amount are indeed present + amount: ing.amount ?? 0, + unit: ing.unit, + //subtext: ing.subtext ?? undefined, + })), + })), + imageUrl: undefined, // not part of DTO yet, placeholder + } +} + +/** + * Maps a Recipe model (as used in the frontend) back to a RecipeDto + * for sending updates or creations to the backend. + */ +export function mapRecipeModelToDto(model: RecipeModel): RecipeDto { + // Split instructions string back into steps + // @todo implement instructions properly... + /* const instructionLines = model.instructions + .split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0) + + const instructionDtos: RecipeInstructionStepDto[] = instructionLines.map( + (text, index) => ({ + id: crypto.randomUUID(), // or keep existing IDs if you track them in model + text, + sortOrder: index + 1, + }) + ) */ + const instructionDtos = [{ + text: model.instructions, + sortOrder: 1 + }] + + const ingredientGroupDtos: RecipeIngredientGroupDto[] = + model.ingredientGroupList.map((group, groupIndex) => ({ + id: group.id, + title: group.title, + sortOrder: groupIndex + 1, // sortOrder from list index + ingredients: group.ingredientList.map((ing, ingIndex) => ({ + id: ing.id, + name: ing.name, + amount: ing.amount, + unit: ing.unit, + sortOrder: ingIndex + 1, // sortOrder from index + //subtext: ing.subtext ?? null, + })), + })) + + return { + id: model.id, + title: model.title, + amount: model.servings.amount, + amountDescription: model.servings.unit, + instructions: instructionDtos, + ingredientGroups: ingredientGroupDtos, + } +} diff --git a/frontend/src/types/ingredientGroup.ts b/frontend/src/models/IngredientGroupModel.ts similarity index 73% rename from frontend/src/types/ingredientGroup.ts rename to frontend/src/models/IngredientGroupModel.ts index b5c89cd..0e12267 100644 --- a/frontend/src/types/ingredientGroup.ts +++ b/frontend/src/models/IngredientGroupModel.ts @@ -1,4 +1,4 @@ -import type { Ingredient } from "./ingredient" +import type { IngredientModel } from "./IngredientModel" /** * A group of ingredients * Consisting of title and ingredient list, this interface is used to group @@ -6,7 +6,8 @@ import type { Ingredient } from "./ingredient" * icing of a cake */ -export interface IngredientGroup { +export interface IngredientGroupModel { + id?: string /** * Title of the group describing its purpose * The title is optional as recipes consisting of a single ingredient group usually don't @@ -14,5 +15,5 @@ export interface IngredientGroup { */ title? : string /** Ingredients */ - ingredientList : Ingredient[] + ingredientList : IngredientModel[] } \ No newline at end of file diff --git a/frontend/src/types/ingredient.ts b/frontend/src/models/IngredientModel.ts similarity index 85% rename from frontend/src/types/ingredient.ts rename to frontend/src/models/IngredientModel.ts index 6fd414f..4eea16e 100644 --- a/frontend/src/types/ingredient.ts +++ b/frontend/src/models/IngredientModel.ts @@ -1,7 +1,8 @@ /** * Represents a single ingredient in a recipe. */ -export interface Ingredient { +export interface IngredientModel { + id?: string /** Name of the ingredient (e.g. "Spaghetti") */ name: string diff --git a/frontend/src/types/recipe.ts b/frontend/src/models/RecipeModel.ts similarity index 76% rename from frontend/src/types/recipe.ts rename to frontend/src/models/RecipeModel.ts index 1329fff..9098321 100644 --- a/frontend/src/types/recipe.ts +++ b/frontend/src/models/RecipeModel.ts @@ -1,5 +1,5 @@ -import type { IngredientGroup } from "./ingredientGroup" -import type { Servings } from "./servings" +import type { IngredientGroupModel } from "./IngredientGroupModel" +import type { ServingsModel } from "./ServingsModel" /** * Represents a recipe object in the application. @@ -11,21 +11,21 @@ import type { Servings } from "./servings" * - adapt RecipeDetailView * - add an IngredientGroupListEditor for handling IngredientGroups */ -export interface Recipe { +export interface RecipeModel { /** Unique identifier for the recipe */ - id: string + id?: string /** Title of the recipe */ title: string /** List of ingredients groups containing the ingredients of the recipe */ - ingredientGroupList: IngredientGroup[] + ingredientGroupList: IngredientGroupModel[] /** Preparation instructions */ instructions: string /** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */ - servings: Servings + servings: ServingsModel /** Unit for the quantity */ diff --git a/frontend/src/types/servings.ts b/frontend/src/models/ServingsModel.ts similarity index 86% rename from frontend/src/types/servings.ts rename to frontend/src/models/ServingsModel.ts index be3808f..a4f9daa 100644 --- a/frontend/src/types/servings.ts +++ b/frontend/src/models/ServingsModel.ts @@ -2,7 +2,7 @@ * Defines how many servings of the dish are prepared when following the recipe */ -export interface Servings{ +export interface ServingsModel{ /** Amount of servings */ amount: number,