Renaming of rest resources
This commit is contained in:
parent
1487bb73a1
commit
0dc3264a22
12 changed files with 328 additions and 79 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
|
import {AbstractDto} from "./AbstractDto.ts";
|
||||||
|
import {RecipeIngredientGroupDto} from "./RecipeIngredientGroupDto.js";
|
||||||
|
import {RecipeInstructionStepDto} from "./RecipeInstructionStepDto.js";
|
||||||
|
import type {TagDto} from "./TagDto.ts";
|
||||||
|
|
||||||
import { AbstractDto } from "./AbstractDto.ts";
|
|
||||||
import { RecipeIngredientGroupDto } from "./RecipeIngredientGroupDto.js";
|
|
||||||
import { RecipeInstructionStepDto } from "./RecipeInstructionStepDto.js";
|
|
||||||
/**
|
/**
|
||||||
* DTO describing a recipe
|
* DTO describing a recipe
|
||||||
*/
|
*/
|
||||||
|
|
@ -12,4 +13,6 @@ export class RecipeDto extends AbstractDto {
|
||||||
amountDescription?: string;
|
amountDescription?: string;
|
||||||
instructions!: RecipeInstructionStepDto[];
|
instructions!: RecipeInstructionStepDto[];
|
||||||
ingredientGroups!: RecipeIngredientGroupDto[];
|
ingredientGroups!: RecipeIngredientGroupDto[];
|
||||||
|
/** Tags associated with this recipe */
|
||||||
|
tagList?: TagDto[];
|
||||||
}
|
}
|
||||||
8
frontend/src/api/dtos/TagDto.ts
Normal file
8
frontend/src/api/dtos/TagDto.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {AbstractDto} from "./AbstractDto.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO describing a tag
|
||||||
|
*/
|
||||||
|
export class TagDto extends AbstractDto {
|
||||||
|
description!: string;
|
||||||
|
}
|
||||||
27
frontend/src/api/endpoints/TagRestResource.ts
Normal file
27
frontend/src/api/endpoints/TagRestResource.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import {apiClient} from "../apiClient"
|
||||||
|
import type {TagDto} from "../dtos/TagDto"
|
||||||
|
|
||||||
|
const TAG_ENDPOINT = "tags"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all existing tags from the backend.
|
||||||
|
*/
|
||||||
|
export async function fetchAllTags(): Promise<TagDto[]> {
|
||||||
|
return apiClient.get<TagDto[]>(TAG_ENDPOINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new tag or updates an existing one.
|
||||||
|
* @param tag The tag to create or update
|
||||||
|
*/
|
||||||
|
export async function createOrUpdateTag(tag: TagDto): Promise<TagDto> {
|
||||||
|
return apiClient.post<TagDto>(`${TAG_ENDPOINT}/create-or-update`, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a tag by its ID.
|
||||||
|
* @param id The ID of the tag to delete
|
||||||
|
*/
|
||||||
|
export async function deleteTag(id: string): Promise<void> {
|
||||||
|
return apiClient.delete(`${TAG_ENDPOINT}/${id}`)
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import {useNavigate, useParams} from "react-router-dom"
|
||||||
import {useEffect, useState} from "react"
|
import {useEffect, useState} from "react"
|
||||||
import type {RecipeModel} from "../../models/RecipeModel"
|
import type {RecipeModel} from "../../models/RecipeModel"
|
||||||
import {RecipeEditor} from "./RecipeEditor"
|
import {RecipeEditor} from "./RecipeEditor"
|
||||||
import {createOrUpdateRecipe, fetchRecipe} from "../../api/points/RecipePoint"
|
import {createOrUpdateRecipe, fetchRecipe} from "../../api/endpoints/RecipeRestResource.ts"
|
||||||
import {getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
|
import {getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
|
||||||
import {mapRecipeDtoToModel, mapRecipeModelToDto} from "../../mappers/RecipeMapper"
|
import {mapRecipeDtoToModel, mapRecipeModelToDto} from "../../mappers/RecipeMapper"
|
||||||
import type {RecipeDto} from "../../api/dtos/RecipeDto"
|
import type {RecipeDto} from "../../api/dtos/RecipeDto"
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import {IngredientGroupListEditor} from "./IngredientGroupListEditor"
|
||||||
import Button from "../basics/Button"
|
import Button from "../basics/Button"
|
||||||
import {InstructionStepListEditor} from "./InstructionStepListEditor"
|
import {InstructionStepListEditor} from "./InstructionStepListEditor"
|
||||||
import type {InstructionStepModel} from "../../models/InstructionStepModel"
|
import type {InstructionStepModel} from "../../models/InstructionStepModel"
|
||||||
|
import type {TagModel} from "../../models/TagModel"
|
||||||
|
import {TagListEditor} from "../tags/TagListEditor"
|
||||||
import {ButtonType} from "../basics/BasicButtonDefinitions"
|
import {ButtonType} from "../basics/BasicButtonDefinitions"
|
||||||
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
|
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx"
|
||||||
import ContentBody from "../basics/ContentBody.tsx";
|
import ContentBody from "../basics/ContentBody.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"
|
||||||
|
|
||||||
type RecipeEditorProps = {
|
type RecipeEditorProps = {
|
||||||
recipe: RecipeModel
|
recipe: RecipeModel
|
||||||
|
|
@ -23,46 +25,30 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
||||||
/** Error list */
|
/** Error list */
|
||||||
const [errors, setErrors] = useState<{ title?: boolean; ingredients?: boolean }>({})
|
const [errors, setErrors] = useState<{ title?: boolean; ingredients?: boolean }>({})
|
||||||
|
|
||||||
/**
|
|
||||||
* Update ingredients
|
|
||||||
* @param ingredientGroupList updated ingredient groups and ingredients
|
|
||||||
*/
|
|
||||||
const updateIngredientGroupList = (ingredientGroupList: IngredientGroupModel[]) => {
|
const updateIngredientGroupList = (ingredientGroupList: IngredientGroupModel[]) => {
|
||||||
setDraft({...draft, ingredientGroupList})
|
setDraft({...draft, ingredientGroupList})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Update instruction steps
|
|
||||||
* @param instructionStepList updated instructions
|
|
||||||
*/
|
|
||||||
const updateInstructionList = (instructionStepList: InstructionStepModel[]) => {
|
const updateInstructionList = (instructionStepList: InstructionStepModel[]) => {
|
||||||
setDraft({...draft, instructionStepList})
|
setDraft({...draft, instructionStepList})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const updateTagList = (tagList: TagModel[]) => {
|
||||||
* Validate recipe
|
setDraft({...draft, tagList})
|
||||||
* @returns Information on the errors the validation encountered
|
}
|
||||||
*/
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const newErrors: { title?: boolean; ingredients?: boolean } = {}
|
const newErrors: { title?: boolean; ingredients?: boolean } = {}
|
||||||
|
|
||||||
// each recipe requires a title
|
|
||||||
if (!draft.title.trim()) {
|
if (!draft.title.trim()) {
|
||||||
newErrors.title = true
|
newErrors.title = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/* there must be at least one ingredient group
|
|
||||||
* no group may contain an empty ingredient list
|
|
||||||
* @todo check whether all ingredients are valid
|
|
||||||
* @todo enhance visualization of ingredient errors
|
|
||||||
*/
|
|
||||||
if (!draft.ingredientGroupList || draft.ingredientGroupList.length === 0) {
|
if (!draft.ingredientGroupList || draft.ingredientGroupList.length === 0) {
|
||||||
newErrors.ingredients = true
|
newErrors.ingredients = true
|
||||||
} else {
|
} else {
|
||||||
const isAnyIngredientListEmpty = draft.ingredientGroupList.some(
|
const isAnyIngredientListEmpty = draft.ingredientGroupList.some(
|
||||||
ingGrp => {
|
ingGrp => !ingGrp.ingredientList || ingGrp.ingredientList.length === 0
|
||||||
return !ingGrp.ingredientList || ingGrp.ingredientList.length === 0
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
if (isAnyIngredientListEmpty) {
|
if (isAnyIngredientListEmpty) {
|
||||||
newErrors.ingredients = true
|
newErrors.ingredients = true
|
||||||
|
|
@ -70,22 +56,19 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors)
|
setErrors(newErrors)
|
||||||
|
|
||||||
return Object.keys(newErrors).length === 0
|
return Object.keys(newErrors).length === 0
|
||||||
}
|
}
|
||||||
/** Handles saving and ensures that the draft is only saved if valid */
|
|
||||||
const handleSave = (draft: RecipeModel) => {
|
const handleSave = (draft: RecipeModel) => {
|
||||||
if (validate()) {
|
if (validate()) {
|
||||||
onSave(draft)
|
onSave(draft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ensure that there is a recipe and show error otherwise
|
|
||||||
if (!recipe) return <div>Oops, there's no recipe in RecipeEditor...</div>
|
if (!recipe) return <div>Oops, there's no recipe in RecipeEditor...</div>
|
||||||
// @todo add handling of images
|
|
||||||
return (
|
return (
|
||||||
/*Container spanning entire screen used to center content horizontally */
|
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Container defining the maximum width of the content */}
|
|
||||||
<PageContentLayout>
|
<PageContentLayout>
|
||||||
<h1 className="border-b-2 border-gray-300 pb-4">
|
<h1 className="border-b-2 border-gray-300 pb-4">
|
||||||
{recipe.id ? "Rezept bearbeiten" : "Neues Rezept"}
|
{recipe.id ? "Rezept bearbeiten" : "Neues Rezept"}
|
||||||
|
|
@ -99,6 +82,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
onChange={e => setDraft({...draft, title: e.target.value})}
|
onChange={e => setDraft({...draft, title: e.target.value})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Servings */}
|
{/* Servings */}
|
||||||
<h2>Portionen</h2>
|
<h2>Portionen</h2>
|
||||||
<div className="columns-3 gap-2 flex items-center">
|
<div className="columns-3 gap-2 flex items-center">
|
||||||
|
|
@ -124,7 +108,15 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Ingredient List - @todo better visualization of errors! */}
|
|
||||||
|
{/* Tags */}
|
||||||
|
<h2>Tags</h2>
|
||||||
|
<TagListEditor
|
||||||
|
tagList={draft.tagList ?? []}
|
||||||
|
onChange={updateTagList}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Ingredient List */}
|
||||||
<div className={errors.ingredients ? "border error-text rounded p-2" : ""}>
|
<div className={errors.ingredients ? "border error-text rounded p-2" : ""}>
|
||||||
<IngredientGroupListEditor
|
<IngredientGroupListEditor
|
||||||
ingredientGroupList={draft.ingredientGroupList}
|
ingredientGroupList={draft.ingredientGroupList}
|
||||||
|
|
@ -132,15 +124,13 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instruction List*/}
|
{/* Instruction List */}
|
||||||
<InstructionStepListEditor
|
<InstructionStepListEditor
|
||||||
instructionStepList={draft.instructionStepList}
|
instructionStepList={draft.instructionStepList}
|
||||||
onChange={updateInstructionList}
|
onChange={updateInstructionList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<ButtonGroupLayout>
|
<ButtonGroupLayout>
|
||||||
{/* Save Button */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleSave(draft)}
|
onClick={() => handleSave(draft)}
|
||||||
text={"Speichern"}
|
text={"Speichern"}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
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 {RecipeModel} from "../../models/RecipeModel"
|
||||||
import {fetchRecipeList} from "../../api/points/CompactRecipePoint"
|
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"
|
||||||
|
|
|
||||||
26
frontend/src/components/tags/TagChip.tsx
Normal file
26
frontend/src/components/tags/TagChip.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type {TagModel} from "../../models/TagModel.ts"
|
||||||
|
|
||||||
|
type TagChipProps = {
|
||||||
|
tag: TagModel
|
||||||
|
onRemove: (tag: TagModel) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a single tag as a styled chip with a remove (×) button.
|
||||||
|
*/
|
||||||
|
export function TagChip({tag, onRemove}: TagChipProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
|
||||||
|
{tag.description}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(tag)}
|
||||||
|
className="ml-1 text-blue-500 hover:text-blue-800 focus:outline-none leading-none"
|
||||||
|
aria-label={`Remove tag ${tag.description}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
161
frontend/src/components/tags/TagListEditor.tsx
Normal file
161
frontend/src/components/tags/TagListEditor.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
import {useEffect, useRef, useState} from "react"
|
||||||
|
import type {TagModel} from "../../models/TagModel"
|
||||||
|
import {TagChip} from "./TagChip"
|
||||||
|
import {createOrUpdateTag, fetchAllTags} from "../../api/endpoints/TagRestResource.ts"
|
||||||
|
|
||||||
|
type TagListEditorProps = {
|
||||||
|
/** The tags currently assigned to the recipe */
|
||||||
|
tagList: TagModel[]
|
||||||
|
/** Called whenever the tag list changes */
|
||||||
|
onChange: (tagList: TagModel[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tag editor inspired by Jira's label picker.
|
||||||
|
*
|
||||||
|
* - Shows existing tags as chips with a × button to remove them.
|
||||||
|
* - An input field lets the user search through all available tags.
|
||||||
|
* - Matching tags are shown in a dropdown; the user can pick one or
|
||||||
|
* confirm the typed text as a brand-new tag with "Create '<text>'" option.
|
||||||
|
*/
|
||||||
|
export function TagListEditor({tagList, onChange}: TagListEditorProps) {
|
||||||
|
const [inputValue, setInputValue] = useState("")
|
||||||
|
const [availableTags, setAvailableTags] = useState<TagModel[]>([])
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Load all available tags once on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
fetchAllTags()
|
||||||
|
.then(dtos =>
|
||||||
|
setAvailableTags(dtos.map(dto => ({id: dto.id, description: dto.description})))
|
||||||
|
)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsDropdownOpen(false)
|
||||||
|
setInputValue("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const trimmed = inputValue.trim().toLowerCase()
|
||||||
|
|
||||||
|
/** Tags matching the current input that are not already selected */
|
||||||
|
const filteredSuggestions = availableTags.filter(
|
||||||
|
tag =>
|
||||||
|
tag.description.toLowerCase().includes(trimmed) &&
|
||||||
|
!tagList.some(t => t.id === tag.id || t.description.toLowerCase() === tag.description.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether the typed text is a genuinely new tag (not in available list) */
|
||||||
|
const isNewTag =
|
||||||
|
trimmed.length > 0 &&
|
||||||
|
!availableTags.some(t => t.description.toLowerCase() === trimmed)
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(e.target.value)
|
||||||
|
setIsDropdownOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputFocus = () => {
|
||||||
|
setIsDropdownOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTag = (tag: TagModel) => {
|
||||||
|
onChange([...tagList, tag])
|
||||||
|
setInputValue("")
|
||||||
|
setIsDropdownOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewTag = async () => {
|
||||||
|
if (!trimmed) return
|
||||||
|
try {
|
||||||
|
// Persist to backend; backend returns the tag with its new ID
|
||||||
|
const created = await createOrUpdateTag({id: undefined as unknown as string, description: trimmed})
|
||||||
|
const newTag: TagModel = {id: created.id, description: created.description}
|
||||||
|
// Add to local available list so it shows up in future searches
|
||||||
|
setAvailableTags(prev => [...prev, newTag])
|
||||||
|
selectTag(newTag)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create tag", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (tagToRemove: TagModel) => {
|
||||||
|
onChange(tagList.filter(t => t !== tagToRemove))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
if (filteredSuggestions.length > 0 && !isNewTag) {
|
||||||
|
selectTag(filteredSuggestions[0])
|
||||||
|
} else if (isNewTag) {
|
||||||
|
createNewTag()
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setIsDropdownOpen(false)
|
||||||
|
setInputValue("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDropdown = isDropdownOpen && (filteredSuggestions.length > 0 || isNewTag)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
{/* Selected tags — only rendered when at least one tag is present */}
|
||||||
|
{tagList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{tagList.map((tag, index) => (
|
||||||
|
<TagChip key={tag.id ?? index} tag={tag} onRemove={removeTag}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={isLoading ? "Loading tags…" : "Add tag…"}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{showDropdown && (
|
||||||
|
<ul className="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded shadow-lg max-h-48 overflow-y-auto text-sm">
|
||||||
|
{filteredSuggestions.map(tag => (
|
||||||
|
<li
|
||||||
|
key={tag.id}
|
||||||
|
onMouseDown={() => selectTag(tag)}
|
||||||
|
className="px-3 py-2 cursor-pointer hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
{tag.description}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{isNewTag && (
|
||||||
|
<li
|
||||||
|
onMouseDown={createNewTag}
|
||||||
|
className="px-3 py-2 cursor-pointer hover:bg-green-50 text-green-700 font-medium border-t border-gray-100"
|
||||||
|
>
|
||||||
|
Create “{inputValue.trim()}”
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import type {RecipeModel} from "../models/RecipeModel"
|
||||||
import type {RecipeDto} from "../api/dtos/RecipeDto"
|
import type {RecipeDto} from "../api/dtos/RecipeDto"
|
||||||
import type {RecipeIngredientGroupDto} from "../api/dtos/RecipeIngredientGroupDto"
|
import type {RecipeIngredientGroupDto} from "../api/dtos/RecipeIngredientGroupDto"
|
||||||
import type {RecipeInstructionStepDto} from "../api/dtos/RecipeInstructionStepDto"
|
import type {RecipeInstructionStepDto} from "../api/dtos/RecipeInstructionStepDto"
|
||||||
|
import {mapTagDtoToModel, mapTagModelToDto} from "./TagMapper"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps a RecipeDto (as returned by the backend) to the Recipe model
|
* Maps a RecipeDto (as returned by the backend) to the Recipe model
|
||||||
|
|
@ -16,36 +17,33 @@ export function mapRecipeDtoToModel(dto: RecipeDto): RecipeModel {
|
||||||
amount: dto.amount ?? 1,
|
amount: dto.amount ?? 1,
|
||||||
unit: dto.amountDescription ?? "",
|
unit: dto.amountDescription ?? "",
|
||||||
},
|
},
|
||||||
// join all instruction step texts into a single string for display
|
|
||||||
instructionStepList: dto.instructions
|
instructionStepList: dto.instructions
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure correct order
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
.map(step => ({
|
.map(step => ({
|
||||||
text: step.text,
|
text: step.text,
|
||||||
id: step.id,
|
id: step.id,
|
||||||
/* When mapping a stepDTO, it should already contain a UUID. If
|
internalId: step.id !== undefined ? step.id : crypto.randomUUID()
|
||||||
* If, however, for some reason, it does not, add a UUID as sorting
|
})),
|
||||||
* steps in teh GUI requires a unique identifier for each item.
|
|
||||||
*/
|
|
||||||
internalId: step.id !== undefined ? step.id : crypto.randomUUID()
|
|
||||||
})
|
|
||||||
),
|
|
||||||
|
|
||||||
ingredientGroupList: dto.ingredientGroups
|
ingredientGroupList: dto.ingredientGroups
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure groups are ordered
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
.map(group => ({
|
.map(group => ({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
title: group.title,
|
title: group.title,
|
||||||
ingredientList: group.ingredients
|
ingredientList: group.ingredients
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure ingredients are ordered
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
.map(ing => ({
|
.map(ing => ({
|
||||||
id: ing.id,
|
id: ing.id,
|
||||||
name: ing.name ?? "", // @todo ensure that name and amount are indeed present
|
name: ing.name ?? "",
|
||||||
amount: ing.amount,
|
amount: ing.amount,
|
||||||
unit: ing.unit,
|
unit: ing.unit,
|
||||||
//subtext: ing.subtext ?? undefined,
|
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
imageUrl: undefined, // not part of DTO yet, placeholder
|
|
||||||
|
tagList: (dto.tagList ?? []).map(mapTagDtoToModel),
|
||||||
|
|
||||||
|
imageUrl: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +52,6 @@ export function mapRecipeDtoToModel(dto: RecipeDto): RecipeModel {
|
||||||
* for sending updates or creations to the backend.
|
* for sending updates or creations to the backend.
|
||||||
*/
|
*/
|
||||||
export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
|
export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
|
||||||
// Map instructions
|
|
||||||
const instructionDtos: RecipeInstructionStepDto[] = model.instructionStepList.map(
|
const instructionDtos: RecipeInstructionStepDto[] = model.instructionStepList.map(
|
||||||
(step, index) => ({
|
(step, index) => ({
|
||||||
id: step.id,
|
id: step.id,
|
||||||
|
|
@ -63,22 +60,22 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// Map ingredients
|
|
||||||
const ingredientGroupDtos: RecipeIngredientGroupDto[] =
|
const ingredientGroupDtos: RecipeIngredientGroupDto[] =
|
||||||
model.ingredientGroupList.map((group, groupIndex) => ({
|
model.ingredientGroupList.map((group, groupIndex) => ({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
title: group.title,
|
title: group.title,
|
||||||
sortOrder: groupIndex + 1, // sortOrder from list index
|
sortOrder: groupIndex + 1,
|
||||||
ingredients: group.ingredientList.map((ing, ingIndex) => ({
|
ingredients: group.ingredientList.map((ing, ingIndex) => ({
|
||||||
id: ing.id,
|
id: ing.id,
|
||||||
name: ing.name,
|
name: ing.name,
|
||||||
amount: ing.amount,
|
amount: ing.amount,
|
||||||
unit: ing.unit,
|
unit: ing.unit,
|
||||||
sortOrder: ingIndex + 1, // sortOrder from index
|
sortOrder: ingIndex + 1,
|
||||||
//subtext: ing.subtext ?? null,
|
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const tagDtos = model.tagList.map(mapTagModelToDto)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
title: model.title,
|
title: model.title,
|
||||||
|
|
@ -86,5 +83,6 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
|
||||||
amountDescription: model.servings.unit,
|
amountDescription: model.servings.unit,
|
||||||
instructions: instructionDtos,
|
instructions: instructionDtos,
|
||||||
ingredientGroups: ingredientGroupDtos,
|
ingredientGroups: ingredientGroupDtos,
|
||||||
|
tagList: tagDtos,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
22
frontend/src/mappers/TagMapper.ts
Normal file
22
frontend/src/mappers/TagMapper.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type {TagModel} from "../models/TagModel"
|
||||||
|
import type {TagDto} from "../api/dtos/TagDto"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a TagDto (as returned by the backend) to the TagModel used in the frontend.
|
||||||
|
*/
|
||||||
|
export function mapTagDtoToModel(dto: TagDto): TagModel {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
description: dto.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a TagModel (as used in the frontend) back to a TagDto for sending to the backend.
|
||||||
|
*/
|
||||||
|
export function mapTagModelToDto(model: TagModel): TagDto {
|
||||||
|
return {
|
||||||
|
id: model.id!,
|
||||||
|
description: model.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import type { IngredientGroupModel } from "./IngredientGroupModel"
|
import type {IngredientGroupModel} from "./IngredientGroupModel"
|
||||||
import type { InstructionStepModel } from "./InstructionStepModel"
|
import type {InstructionStepModel} from "./InstructionStepModel"
|
||||||
import type { ServingsModel } from "./ServingsModel"
|
import type {ServingsModel} from "./ServingsModel"
|
||||||
|
import type {TagModel} from "./TagModel.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a recipe object in the application.
|
* Represents a recipe object in the application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @todo ingredient groups! There may be serveral ingredient lists, each with a title.
|
* @todo ingredient groups! There may be serveral ingredient lists, each with a title.
|
||||||
* e.g. for the dough, for the filling, for the icing,...
|
* e.g. for the dough, for the filling, for the icing,...
|
||||||
|
|
@ -13,24 +15,27 @@ import type { ServingsModel } from "./ServingsModel"
|
||||||
* - add an IngredientGroupListEditor for handling IngredientGroups
|
* - add an IngredientGroupListEditor for handling IngredientGroups
|
||||||
*/
|
*/
|
||||||
export interface RecipeModel {
|
export interface RecipeModel {
|
||||||
/** Unique identifier for the recipe */
|
/** Unique identifier for the recipe */
|
||||||
id?: string
|
id?: string
|
||||||
|
|
||||||
/** Title of the recipe */
|
/** Title of the recipe */
|
||||||
title: string
|
title: string
|
||||||
|
|
||||||
/** List of ingredients groups containing the ingredients of the recipe */
|
/** List of ingredients groups containing the ingredients of the recipe */
|
||||||
ingredientGroupList: IngredientGroupModel[]
|
ingredientGroupList: IngredientGroupModel[]
|
||||||
|
|
||||||
/** Preparation instructions */
|
/** Preparation instructions */
|
||||||
instructionStepList: InstructionStepModel[]
|
instructionStepList: InstructionStepModel[]
|
||||||
|
|
||||||
/** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */
|
/** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */
|
||||||
servings: ServingsModel
|
servings: ServingsModel
|
||||||
|
|
||||||
/** Unit for the quantity */
|
/** Unit for the quantity */
|
||||||
|
|
||||||
/** Optional image URL for the recipe */
|
/** Optional image URL for the recipe */
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
|
|
||||||
|
/** Tags categorising the recipe, e.g. "dessert", "vegetarian", "christmas" */
|
||||||
|
tagList: TagModel[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
9
frontend/src/models/TagModel.ts
Normal file
9
frontend/src/models/TagModel.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Represents a tag in the application.
|
||||||
|
*/
|
||||||
|
export interface TagModel {
|
||||||
|
/** Unique identifier for the tag */
|
||||||
|
id?: string
|
||||||
|
/** Human-readable label, e.g. "dessert", "vegetarian", "christmas" */
|
||||||
|
description: string
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue