renamed models, added mapper for recipes

This commit is contained in:
Anika Raemer 2025-10-07 20:53:31 +02:00
parent 7a6f5b5bcd
commit 8027fce80d
21 changed files with 164 additions and 61 deletions

View file

@ -0,0 +1,5 @@
export abstract class AbstractDto {
id?: string;
createdAt?: Date;
updatedAt?: Date;
}

View file

@ -1,5 +1,5 @@
import { AbstractDto } from "./AbstractDto.js";
import { AbstractDto } from "./AbstractDto.ts";
import { RecipeIngredientGroupDto } from "./RecipeIngredientGroupDto.js";
import { RecipeInstructionStepDto } from "./RecipeInstructionStepDto.js";
/**

View file

@ -1,4 +1,4 @@
import { AbstractDto } from "./AbstractDto.js";
import { AbstractDto } from "./AbstractDto.ts";
export class RecipeIngredientDto extends AbstractDto{
name!: string;

View file

@ -1,4 +1,4 @@
import { AbstractDto } from "./AbstractDto.js";
import { AbstractDto } from "./AbstractDto.ts";
import { RecipeIngredientDto } from "./RecipeIngredientDto.js";
export class RecipeIngredientGroupDto extends AbstractDto{

View file

@ -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;

View file

@ -1,4 +1,4 @@
import { AbstractDto } from "./AbstractDto.js";
import { AbstractDto } from "./AbstractDto.ts";
export class UserDto extends AbstractDto {
firstName?: string;

View file

@ -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";
/**

View file

@ -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<Recipe[]> {
export async function fetchRecipeList(searchString : string): Promise<RecipeModel[]> {
let url : string = RECIPE_URL;
// if there's a search string add it as query parameter
if(searchString && searchString !== ""){

View file

@ -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<Recipe> {
export async function fetchRecipe(id: string): Promise<RecipeDto> {
const res = await get(`${RECIPE_URL}/${id}`)
return res.json()
}
@ -28,7 +28,7 @@ export async function fetchRecipe(id: string): Promise<Recipe> {
* @param recipe Recipe to create
* @returns Saved recipe
*/
export async function createRecipe(recipe: Recipe): Promise<Recipe> {
export async function createRecipe(recipe: RecipeDto): Promise<RecipeDto> {
const res = await postJson(RECIPE_URL, JSON.stringify(recipe));
return res.json();
}
@ -38,7 +38,7 @@ export async function createRecipe(recipe: Recipe): Promise<Recipe> {
* @param recipe Recipe to save. This recipe must have an ID!
* @returns Saved recipe
*/
export async function updateRecipe(recipe: Recipe): Promise<Recipe> {
export async function updateRecipe(recipe: RecipeDto): Promise<RecipeDto> {
const res = await putJson(`${RECIPE_URL}/${recipe.id}`, JSON.stringify(recipe));
return res.json();
}

View file

@ -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', '*');

View file

@ -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 (
<div>
{/* 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 */}
<h3 className="subsection-heading" >Ingredient Groups</h3>
{ingredientGroupList.map((ingGrp, index) => (
<div key={index} className="ingredient-group-card mb-4">

View file

@ -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
)

View file

@ -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<Recipe | null>(null)
const [recipe, setRecipe] = useState<RecipeModel | null>(null)
// Working copy for re-calculating ingredients
const [recipeWorkingCopy, setRecipeWorkingCopy] = useState<Recipe|null>(null)
const [recipeWorkingCopy, setRecipeWorkingCopy] = useState<RecipeModel|null>(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 */}
<div className="button-group">
<ButtonLink
to={getRecipeEditUrl(recipe.id)}
to={recipe.id !== undefined ? getRecipeEditUrl(recipe.id) : getRecipeListUrl()} // @todo show error instead
className="basic-button primary-button-bg primary-button-text"
text="Bearbeiten"
/>

View file

@ -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<Recipe | null>(null)
const [recipe, setRecipe] = useState<RecipeModel | null>(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) {

View file

@ -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>(recipe)
const [draft, setDraft] = useState<RecipeModel>(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)
}

View file

@ -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<Recipe[]|null>(null)
const [recipeList, setRecipeList] = useState<RecipeModel[]|null>(null)
const [searchString, setSearchString] = useState<string>("")
// 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() {
<RecipeListItem
key={recipe.id}
title={recipe.title}
targetPath={getRecipeDetailUrl(recipe.id)}
targetPath={recipe.id !== undefined ? getRecipeDetailUrl(recipe.id) : getRecipeListUrl()} // @todo proper error handling
/>
))}
</div>

View file

@ -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,
}
}

View file

@ -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[]
}

View file

@ -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

View file

@ -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 */

View file

@ -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,