Renaming of rest resources

This commit is contained in:
araemer 2026-02-21 07:44:26 +01:00
parent 1487bb73a1
commit 0dc3264a22
12 changed files with 328 additions and 79 deletions

View file

@ -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
*/
@ -12,4 +13,6 @@ export class RecipeDto extends AbstractDto {
amountDescription?: string;
instructions!: RecipeInstructionStepDto[];
ingredientGroups!: RecipeIngredientGroupDto[];
/** Tags associated with this recipe */
tagList?: TagDto[];
}

View file

@ -0,0 +1,8 @@
import {AbstractDto} from "./AbstractDto.ts";
/**
* DTO describing a tag
*/
export class TagDto extends AbstractDto {
description!: string;
}

View 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}`)
}

View file

@ -2,7 +2,7 @@ import {useNavigate, useParams} from "react-router-dom"
import {useEffect, useState} from "react"
import type {RecipeModel} from "../../models/RecipeModel"
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 {mapRecipeDtoToModel, mapRecipeModelToDto} from "../../mappers/RecipeMapper"
import type {RecipeDto} from "../../api/dtos/RecipeDto"

View file

@ -5,11 +5,13 @@ import {IngredientGroupListEditor} from "./IngredientGroupListEditor"
import Button from "../basics/Button"
import {InstructionStepListEditor} from "./InstructionStepListEditor"
import type {InstructionStepModel} from "../../models/InstructionStepModel"
import type {TagModel} from "../../models/TagModel"
import {TagListEditor} from "../tags/TagListEditor"
import {ButtonType} from "../basics/BasicButtonDefinitions"
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx";
import ContentBody from "../basics/ContentBody.tsx";
import PageContainer from "../basics/PageContainer.tsx";
import PageContentLayout from "../basics/PageContentLayout.tsx";
import ButtonGroupLayout from "../basics/ButtonGroupLayout.tsx"
import ContentBody from "../basics/ContentBody.tsx"
import PageContainer from "../basics/PageContainer.tsx"
import PageContentLayout from "../basics/PageContentLayout.tsx"
type RecipeEditorProps = {
recipe: RecipeModel
@ -23,46 +25,30 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
/** Error list */
const [errors, setErrors] = useState<{ title?: boolean; ingredients?: boolean }>({})
/**
* Update ingredients
* @param ingredientGroupList updated ingredient groups and ingredients
*/
const updateIngredientGroupList = (ingredientGroupList: IngredientGroupModel[]) => {
setDraft({...draft, ingredientGroupList})
}
/**
* Update instruction steps
* @param instructionStepList updated instructions
*/
const updateInstructionList = (instructionStepList: InstructionStepModel[]) => {
setDraft({...draft, instructionStepList})
}
/**
* Validate recipe
* @returns Information on the errors the validation encountered
*/
const updateTagList = (tagList: TagModel[]) => {
setDraft({...draft, tagList})
}
const validate = () => {
const newErrors: { title?: boolean; ingredients?: boolean } = {}
// each recipe requires a title
if (!draft.title.trim()) {
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) {
newErrors.ingredients = true
} else {
const isAnyIngredientListEmpty = draft.ingredientGroupList.some(
ingGrp => {
return !ingGrp.ingredientList || ingGrp.ingredientList.length === 0
}
ingGrp => !ingGrp.ingredientList || ingGrp.ingredientList.length === 0
)
if (isAnyIngredientListEmpty) {
newErrors.ingredients = true
@ -70,22 +56,19 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
/** Handles saving and ensures that the draft is only saved if valid */
const handleSave = (draft: RecipeModel) => {
if (validate()) {
onSave(draft)
}
}
// ensure that there is a recipe and show error otherwise
if (!recipe) return <div>Oops, there's no recipe in RecipeEditor...</div>
// @todo add handling of images
return (
/*Container spanning entire screen used to center content horizontally */
<PageContainer>
{/* Container defining the maximum width of the content */}
<PageContentLayout>
<h1 className="border-b-2 border-gray-300 pb-4">
{recipe.id ? "Rezept bearbeiten" : "Neues Rezept"}
@ -99,6 +82,7 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
value={draft.title}
onChange={e => setDraft({...draft, title: e.target.value})}
/>
{/* Servings */}
<h2>Portionen</h2>
<div className="columns-3 gap-2 flex items-center">
@ -124,7 +108,15 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
}}
/>
</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" : ""}>
<IngredientGroupListEditor
ingredientGroupList={draft.ingredientGroupList}
@ -132,15 +124,13 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) {
/>
</div>
{/* Instruction List*/}
{/* Instruction List */}
<InstructionStepListEditor
instructionStepList={draft.instructionStepList}
onChange={updateInstructionList}
/>
<ButtonGroupLayout>
{/* Save Button */}
<Button
onClick={() => handleSave(draft)}
text={"Speichern"}

View file

@ -1,7 +1,7 @@
import {useEffect, useState} from "react"
import RecipeListItem from "./RecipeListItem"
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 {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
import RecipeListToolbar from "./RecipeListToolbar"

View 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}`}
>
&times;
</button>
</span>
)
}

View 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 &ldquo;{inputValue.trim()}&rdquo;
</li>
)}
</ul>
)}
</div>
)
}

View file

@ -2,6 +2,7 @@ import type {RecipeModel} from "../models/RecipeModel"
import type {RecipeDto} from "../api/dtos/RecipeDto"
import type {RecipeIngredientGroupDto} from "../api/dtos/RecipeIngredientGroupDto"
import type {RecipeInstructionStepDto} from "../api/dtos/RecipeInstructionStepDto"
import {mapTagDtoToModel, mapTagModelToDto} from "./TagMapper"
/**
* 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,
unit: dto.amountDescription ?? "",
},
// join all instruction step texts into a single string for display
instructionStepList: dto.instructions
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure correct order
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(step => ({
text: step.text,
id: step.id,
/* When mapping a stepDTO, it should already contain a UUID. If
* 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()
})
),
text: step.text,
id: step.id,
internalId: step.id !== undefined ? step.id : crypto.randomUUID()
})),
ingredientGroupList: dto.ingredientGroups
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure groups are ordered
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(group => ({
id: group.id,
title: group.title,
ingredientList: group.ingredients
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure ingredients are ordered
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(ing => ({
id: ing.id,
name: ing.name ?? "", // @todo ensure that name and amount are indeed present
name: ing.name ?? "",
amount: ing.amount,
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.
*/
export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
// Map instructions
const instructionDtos: RecipeInstructionStepDto[] = model.instructionStepList.map(
(step, index) => ({
id: step.id,
@ -63,22 +60,22 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
})
)
// Map ingredients
const ingredientGroupDtos: RecipeIngredientGroupDto[] =
model.ingredientGroupList.map((group, groupIndex) => ({
id: group.id,
title: group.title,
sortOrder: groupIndex + 1, // sortOrder from list index
sortOrder: groupIndex + 1,
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,
sortOrder: ingIndex + 1,
})),
}))
const tagDtos = model.tagList.map(mapTagModelToDto)
return {
id: model.id,
title: model.title,
@ -86,5 +83,6 @@ export function mapRecipeModelToDto(model: RecipeModel): RecipeDto {
amountDescription: model.servings.unit,
instructions: instructionDtos,
ingredientGroups: ingredientGroupDtos,
tagList: tagDtos,
}
}

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

View file

@ -1,10 +1,12 @@
import type { IngredientGroupModel } from "./IngredientGroupModel"
import type { InstructionStepModel } from "./InstructionStepModel"
import type { ServingsModel } from "./ServingsModel"
import type {IngredientGroupModel} from "./IngredientGroupModel"
import type {InstructionStepModel} from "./InstructionStepModel"
import type {ServingsModel} from "./ServingsModel"
import type {TagModel} from "./TagModel.ts";
/**
* Represents a recipe object in the application.
*/
/*
* @todo ingredient groups! There may be serveral ingredient lists, each with a title.
* 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
*/
export interface RecipeModel {
/** Unique identifier for the recipe */
id?: string
/** Unique identifier for the recipe */
id?: string
/** Title of the recipe */
title: string
/** Title of the recipe */
title: string
/** List of ingredients groups containing the ingredients of the recipe */
ingredientGroupList: IngredientGroupModel[]
/** List of ingredients groups containing the ingredients of the recipe */
ingredientGroupList: IngredientGroupModel[]
/** Preparation instructions */
instructionStepList: InstructionStepModel[]
/** Preparation instructions */
instructionStepList: InstructionStepModel[]
/** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */
servings: ServingsModel
/** Number of servings, e.g., for 4 person, 12 cupcakes, 2 glasses */
servings: ServingsModel
/** Unit for the quantity */
/** Unit for the quantity */
/** Optional image URL for the recipe */
imageUrl?: string
/** Optional image URL for the recipe */
imageUrl?: string
/** Tags categorising the recipe, e.g. "dessert", "vegetarian", "christmas" */
tagList: TagModel[]
}

View 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
}