diff --git a/frontend/src/api/dtos/RecipeDto.ts b/frontend/src/api/dtos/RecipeDto.ts index bc5bedc..dbb9085 100644 --- a/frontend/src/api/dtos/RecipeDto.ts +++ b/frontend/src/api/dtos/RecipeDto.ts @@ -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[]; } \ No newline at end of file diff --git a/frontend/src/api/dtos/TagDto.ts b/frontend/src/api/dtos/TagDto.ts new file mode 100644 index 0000000..b20ba40 --- /dev/null +++ b/frontend/src/api/dtos/TagDto.ts @@ -0,0 +1,8 @@ +import {AbstractDto} from "./AbstractDto.ts"; + +/** + * DTO describing a tag + */ +export class TagDto extends AbstractDto { + description!: string; +} \ No newline at end of file diff --git a/frontend/src/api/endpoints/TagRestResource.ts b/frontend/src/api/endpoints/TagRestResource.ts new file mode 100644 index 0000000..6660368 --- /dev/null +++ b/frontend/src/api/endpoints/TagRestResource.ts @@ -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 { + return apiClient.get(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 { + return apiClient.post(`${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 { + return apiClient.delete(`${TAG_ENDPOINT}/${id}`) +} \ No newline at end of file diff --git a/frontend/src/components/recipes/RecipeEditPage.tsx b/frontend/src/components/recipes/RecipeEditPage.tsx index 6a9a5d8..f6fa145 100644 --- a/frontend/src/components/recipes/RecipeEditPage.tsx +++ b/frontend/src/components/recipes/RecipeEditPage.tsx @@ -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" diff --git a/frontend/src/components/recipes/RecipeEditor.tsx b/frontend/src/components/recipes/RecipeEditor.tsx index 154bf6c..bcb29ac 100644 --- a/frontend/src/components/recipes/RecipeEditor.tsx +++ b/frontend/src/components/recipes/RecipeEditor.tsx @@ -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
Oops, there's no recipe in RecipeEditor...
- // @todo add handling of images + return ( - /*Container spanning entire screen used to center content horizontally */ - {/* Container defining the maximum width of the content */}

{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 */}

Portionen

@@ -124,7 +108,15 @@ export function RecipeEditor({recipe, onSave, onCancel}: RecipeEditorProps) { }} />
- {/* Ingredient List - @todo better visualization of errors! */} + + {/* Tags */} +

Tags

+ + + {/* Ingredient List */}
- {/* Instruction List*/} + {/* Instruction List */} - - {/* Save Button */} + + ) +} \ No newline at end of file diff --git a/frontend/src/components/tags/TagListEditor.tsx b/frontend/src/components/tags/TagListEditor.tsx new file mode 100644 index 0000000..ef57aee --- /dev/null +++ b/frontend/src/components/tags/TagListEditor.tsx @@ -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 ''" option. + */ +export function TagListEditor({tagList, onChange}: TagListEditorProps) { + const [inputValue, setInputValue] = useState("") + const [availableTags, setAvailableTags] = useState([]) + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const containerRef = useRef(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) => { + 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) => { + 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 ( +
+ {/* Selected tags — only rendered when at least one tag is present */} + {tagList.length > 0 && ( +
+ {tagList.map((tag, index) => ( + + ))} +
+ )} + + {/* Input */} + + + {/* Dropdown */} + {showDropdown && ( +
    + {filteredSuggestions.map(tag => ( +
  • selectTag(tag)} + className="px-3 py-2 cursor-pointer hover:bg-blue-50" + > + {tag.description} +
  • + ))} + {isNewTag && ( +
  • + Create “{inputValue.trim()}” +
  • + )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/mappers/RecipeMapper.ts b/frontend/src/mappers/RecipeMapper.ts index 397a880..e11422d 100644 --- a/frontend/src/mappers/RecipeMapper.ts +++ b/frontend/src/mappers/RecipeMapper.ts @@ -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, } -} +} \ No newline at end of file diff --git a/frontend/src/mappers/TagMapper.ts b/frontend/src/mappers/TagMapper.ts new file mode 100644 index 0000000..f497eac --- /dev/null +++ b/frontend/src/mappers/TagMapper.ts @@ -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, + } +} \ No newline at end of file diff --git a/frontend/src/models/RecipeModel.ts b/frontend/src/models/RecipeModel.ts index d212a64..dae6935 100644 --- a/frontend/src/models/RecipeModel.ts +++ b/frontend/src/models/RecipeModel.ts @@ -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[] } diff --git a/frontend/src/models/TagModel.ts b/frontend/src/models/TagModel.ts new file mode 100644 index 0000000..674af1e --- /dev/null +++ b/frontend/src/models/TagModel.ts @@ -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 +} \ No newline at end of file