Add filter component for tags to recipe list toolbar
This commit is contained in:
parent
79d62b140e
commit
0804256b6d
5 changed files with 306 additions and 51 deletions
225
frontend/src/components/basics/FilterDropDown.tsx
Normal file
225
frontend/src/components/basics/FilterDropDown.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import {useEffect, useRef, useState} from "react"
|
||||
import {Check, ChevronDown, Search, X} from "lucide-react"
|
||||
import {defaultIconSize} from "./SvgIcon"
|
||||
import clsx from "clsx"
|
||||
|
||||
/**
|
||||
* A single selectable option in the dropdown.
|
||||
* Generic so the component can be reused for tags, categories, users, etc.
|
||||
*/
|
||||
export type FilterOption = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained model for a FilterDropdown instance.
|
||||
* Pass this as a single prop to avoid threading multiple filter-related
|
||||
* props through intermediate components.
|
||||
*/
|
||||
export type FilterDropdownModel = {
|
||||
/** Label shown on the trigger button when nothing is selected, e.g. "Tags" */
|
||||
placeholder: string
|
||||
/** Full list of available options */
|
||||
options: FilterOption[]
|
||||
/** Currently selected option IDs */
|
||||
selectedIds: string[]
|
||||
/** Called whenever the selection changes */
|
||||
onSelectionChanged: (selectedIds: string[]) => void
|
||||
}
|
||||
|
||||
type FilterDropdownProps = {
|
||||
model: FilterDropdownModel
|
||||
/** Optional additional Tailwind classes for the root element */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic multi-select filter dropdown styled similarly to Jira's label filter.
|
||||
*
|
||||
* - Closed state: shows placeholder, single selected label, or "Multiple (n)"
|
||||
* - Open state: search field + scrollable checkbox list + deselect-all footer
|
||||
* - Closes on outside click or Escape
|
||||
*/
|
||||
export default function FilterDropdown({model, className = ""}: FilterDropdownProps) {
|
||||
const {placeholder, options, selectedIds, onSelectionChanged} = model
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [filterText, setFilterText] = useState("")
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
closeDropdown()
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Focus search input when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => searchRef.current?.focus(), 0)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const closeDropdown = () => {
|
||||
setIsOpen(false)
|
||||
setFilterText("")
|
||||
}
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (isOpen) {
|
||||
closeDropdown()
|
||||
} else {
|
||||
setIsOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOption = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChanged(selectedIds.filter(sid => sid !== id))
|
||||
} else {
|
||||
onSelectionChanged([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
const deselectAll = () => onSelectionChanged([])
|
||||
|
||||
// Filter options by search text
|
||||
const visibleOptions = options.filter(opt =>
|
||||
opt.label.toLowerCase().includes(filterText.toLowerCase())
|
||||
)
|
||||
|
||||
// Derive button label
|
||||
const buttonLabel = (() => {
|
||||
if (selectedIds.length === 0) return placeholder
|
||||
if (selectedIds.length === 1) {
|
||||
const match = options.find(o => o.id === selectedIds[0])
|
||||
return match?.label ?? placeholder
|
||||
}
|
||||
return `Multiple (${selectedIds.length})`
|
||||
})()
|
||||
|
||||
const hasSelection = selectedIds.length > 0
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={clsx("relative", className)}>
|
||||
{/* Trigger button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
className={clsx(
|
||||
"flex items-center justify-between gap-2 w-full",
|
||||
"border border-gray-300 rounded px-3 py-2 bg-white",
|
||||
"text-sm text-left leading-tight",
|
||||
"hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-300 transition-colors",
|
||||
hasSelection && "border-blue-400 bg-blue-50 text-blue-800",
|
||||
isOpen && "border-blue-400 ring-2 ring-blue-300"
|
||||
)}
|
||||
>
|
||||
<span className={clsx("truncate", !hasSelection && "text-gray-400")}>
|
||||
{buttonLabel}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={defaultIconSize}
|
||||
className={clsx(
|
||||
"flex-shrink-0 text-gray-400 transition-transform duration-150",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown panel */}
|
||||
{isOpen && (
|
||||
<div className={clsx(
|
||||
"absolute z-50 mt-1 w-full min-w-[220px]",
|
||||
"bg-white border border-gray-200 rounded shadow-lg",
|
||||
"flex flex-col"
|
||||
)}>
|
||||
{/* Search field inside dropdown */}
|
||||
<div className="relative p-2 border-b border-gray-100">
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder="Search…"
|
||||
value={filterText}
|
||||
onChange={e => setFilterText(e.target.value)}
|
||||
onKeyDown={e => e.key === "Escape" && closeDropdown()}
|
||||
className="w-full pl-8 pr-8 py-1.5 text-sm border border-gray-200 rounded focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
/>
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
|
||||
/>
|
||||
{filterText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterText("")}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X size={14}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<ul className="overflow-y-auto max-h-52">
|
||||
{visibleOptions.length === 0 ? (
|
||||
<li className="px-3 py-2 text-sm text-gray-400 italic">
|
||||
No results
|
||||
</li>
|
||||
) : (
|
||||
visibleOptions.map(option => {
|
||||
const isSelected = selectedIds.includes(option.id)
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleOption(option.id)}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 w-full px-3 py-2 text-sm text-left",
|
||||
"hover:bg-blue-50 transition-colors",
|
||||
isSelected && "bg-blue-50 text-blue-800"
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
"flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors",
|
||||
isSelected
|
||||
? "bg-blue-500 border-blue-500"
|
||||
: "bg-white border-gray-300"
|
||||
)}>
|
||||
{isSelected && (
|
||||
<Check size={11} className="text-white" strokeWidth={3}/>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{/* Footer: deselect all */}
|
||||
{hasSelection && (
|
||||
<div className="border-t border-gray-100 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={deselectAll}
|
||||
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-red-500 transition-colors w-full px-1 py-0.5"
|
||||
>
|
||||
<X size={12}/>
|
||||
Deselect all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea
|
|||
const [currentSearchString, setCurrentSearchString] = useState<string>("")
|
||||
|
||||
const changeSearchString = (newSearchString: string) => {
|
||||
console.log(newSearchString);
|
||||
setCurrentSearchString(newSearchString);
|
||||
onSearchStringChanged(newSearchString)
|
||||
}
|
||||
|
|
@ -27,40 +26,28 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea
|
|||
const iconStyle: string = "text-gray-400 hover:text-gray-500";
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
"relative",
|
||||
className)}>
|
||||
{/* Input of searchfield
|
||||
Defines border and behavior. Requires extra padding at both sides to
|
||||
accommodate the icons
|
||||
*/}
|
||||
<div className={clsx("relative", className)}>
|
||||
<input
|
||||
className="pl-10 pr-10"
|
||||
className="w-full pl-10 pr-10 py-2 text-sm border border-gray-300 rounded bg-white leading-tight focus:outline-none focus:ring-2 focus:ring-blue-300 focus:border-blue-400 transition-colors"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
placeholder="Suche"
|
||||
value={currentSearchString}
|
||||
onChange={e => changeSearchString(e.target.value)}
|
||||
/>
|
||||
{/* Right icon: X
|
||||
Clears search string on click
|
||||
*/}
|
||||
{/* Right icon: X — clears search string on click */}
|
||||
<button
|
||||
className={clsx(
|
||||
"absolute right-0 inset-y-0 flex items-center -ml-1 mr-3",
|
||||
"absolute right-0 inset-y-0 flex items-center mr-3",
|
||||
iconStyle)}
|
||||
onClick={() => changeSearchString("")}
|
||||
>
|
||||
<X
|
||||
size={defaultIconSize}
|
||||
/>
|
||||
<X size={defaultIconSize}/>
|
||||
</button>
|
||||
{/* Left icon: Looking glass */}
|
||||
{/* Left icon: looking glass */}
|
||||
<div className={clsx(
|
||||
"absolute left-0 inset-y-0 flex items-center ml-3",
|
||||
iconStyle)}>
|
||||
<Search
|
||||
size={defaultIconSize}
|
||||
/>
|
||||
<Search size={defaultIconSize}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function StickyHeader({children, className = ""}: StickyHeaderPro
|
|||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"sticky top-0 left-0 right-0 bg-gray-100 pb-6 border-b-2 border-gray-300 z-10",
|
||||
"sticky top-0 left-0 right-0 bg-gray-100 pb-4 border-b-2 border-gray-300 z-10",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import {useEffect, useState} from "react"
|
|||
import RecipeListItem from "./RecipeListItem"
|
||||
import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts"
|
||||
import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.ts"
|
||||
import {fetchAllTags} from "../../api/endpoints/TagRestResource.ts"
|
||||
import type {FilterDropdownModel, FilterOption} from "../basics/FilterDropDown.tsx"
|
||||
import {useNavigate} from "react-router-dom"
|
||||
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
|
||||
import RecipeListToolbar from "./RecipeListToolbar"
|
||||
|
|
@ -20,14 +22,27 @@ export default function RecipeListPage() {
|
|||
const navigate = useNavigate()
|
||||
const [recipeList, setRecipeList] = useState<CompactRecipeDto[] | null>(null)
|
||||
const [searchString, setSearchString] = useState<string>("")
|
||||
const [tagIdList, setTagIdList] = useState<string[]>([])
|
||||
const [tagOptions, setTagOptions] = useState<FilterOption[]>([])
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
|
||||
|
||||
// Reload list whenever search string or tag filter changes, debounced to
|
||||
// avoid firing on every keystroke
|
||||
// Load available tags once on mount to populate the filter dropdown
|
||||
useEffect(() => {
|
||||
fetchAllTags()
|
||||
.then(dtos => setTagOptions(
|
||||
dtos
|
||||
// ensure that all tags indeed have ids as this is required by the FilterOption
|
||||
.filter((dto): dto is typeof dto & { id: string } => dto.id !== undefined)
|
||||
// map to filter option
|
||||
.map(dto => ({id: dto.id, label: dto.description}))
|
||||
))
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
// Reload recipe list whenever search string or tag selection changes, debounced
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(async () => {
|
||||
try {
|
||||
const data = await fetchRecipeList(searchString, tagIdList)
|
||||
const data = await fetchRecipeList(searchString, selectedTagIds)
|
||||
setRecipeList(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
|
|
@ -35,10 +50,13 @@ export default function RecipeListPage() {
|
|||
}, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchString, tagIdList])
|
||||
}, [searchString, selectedTagIds])
|
||||
|
||||
const handleAdd = () => {
|
||||
navigate(getRecipeAddUrl())
|
||||
const tagFilterModel: FilterDropdownModel = {
|
||||
placeholder: "Kategorie",
|
||||
options: tagOptions,
|
||||
selectedIds: selectedTagIds,
|
||||
onSelectionChanged: setSelectedTagIds,
|
||||
}
|
||||
|
||||
if (!recipeList) {
|
||||
|
|
@ -54,9 +72,10 @@ export default function RecipeListPage() {
|
|||
<StickyHeader>
|
||||
<h1>Recipes</h1>
|
||||
<RecipeListToolbar
|
||||
onAddClicked={handleAdd}
|
||||
onAddClicked={() => navigate(getRecipeAddUrl())}
|
||||
onSearchStringChanged={setSearchString}
|
||||
numberOfRecipes={recipeList.length}
|
||||
tagFilterModel={tagFilterModel}
|
||||
/>
|
||||
</StickyHeader>
|
||||
{/* Content - List of recipe cards */}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,59 @@
|
|||
import { ButtonType } from "../basics/BasicButtonDefinitions"
|
||||
import {ButtonType} from "../basics/BasicButtonDefinitions"
|
||||
import Button from "../basics/Button"
|
||||
import SearchField from "../basics/SearchField"
|
||||
import FilterDropdown, {type FilterDropdownModel} from "../basics/FilterDropDown"
|
||||
|
||||
/**
|
||||
* Toolbar for RecipeListPage containing searchfield, add recipe button and number of recipes
|
||||
* Toolbar for RecipeListPage containing tag filter, search field, add button and recipe count.
|
||||
*
|
||||
* Single-line (wide): [12 Recipes] [Tags ˅] [Search ×] [Add recipe]
|
||||
* Two-line (narrow): [12 Recipes] [Search ×] [Add recipe]
|
||||
* [Tags ˅ — same width as above]
|
||||
*/
|
||||
type RecepeListToolbarProps = {
|
||||
onSearchStringChanged: (searchString : string) => void
|
||||
type RecipeListToolbarProps = {
|
||||
onSearchStringChanged: (searchString: string) => void
|
||||
onAddClicked: () => void
|
||||
numberOfRecipes : number
|
||||
numberOfRecipes: number
|
||||
tagFilterModel: FilterDropdownModel
|
||||
}
|
||||
|
||||
export default function RecipeListToolbar({onSearchStringChanged, onAddClicked, numberOfRecipes} : RecepeListToolbarProps){
|
||||
export default function RecipeListToolbar({
|
||||
onSearchStringChanged,
|
||||
onAddClicked,
|
||||
numberOfRecipes,
|
||||
tagFilterModel,
|
||||
}: RecipeListToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 w-full">
|
||||
{/* Label: left-aligned on medium+ screens, full-width on small screens */}
|
||||
<div className="order-2 md:order-1 w-full md:w-auto md:flex-1">
|
||||
<label className="label">{numberOfRecipes} Recipes</label>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
|
||||
{/* Recipe count — left side, grows to push right-side group to the edge */}
|
||||
<div className="flex-1">
|
||||
<label className="label">{numberOfRecipes} Rezepte</label>
|
||||
</div>
|
||||
|
||||
{/* Search + Add button container: right-aligned on medium+ screens */}
|
||||
<div className="order-1 md:order-2 flex flex-1 md:flex-none justify-end gap-2 min-w-[160px]">
|
||||
<div className="flex-1 md:flex-none md:max-w-[500px]">
|
||||
<SearchField onSearchStringChanged={onSearchStringChanged} />
|
||||
{/* Right-side group: wraps internally so Tags falls under Search+Button
|
||||
and inherits exactly the same width */}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
|
||||
{/* Tags — order-2 so it wraps to row 2 below Search+Button */}
|
||||
<div className="order-2 w-full md:order-1 md:w-auto md:min-w-[100px] md:max-w-[200px]">
|
||||
<FilterDropdown model={tagFilterModel} className="w-full"/>
|
||||
</div>
|
||||
<Button
|
||||
buttonType = {ButtonType.PrimaryButton}
|
||||
className="flex-shrink-0"
|
||||
onClick={onAddClicked}
|
||||
text = "Add recipe"
|
||||
/>
|
||||
|
||||
{/* Search + Button — order-1 so they stay on row 1 when wrapping */}
|
||||
<div className="order-1 md:order-2 flex gap-2 flex-shrink-0">
|
||||
<SearchField
|
||||
onSearchStringChanged={onSearchStringChanged}
|
||||
className="w-[260px]"
|
||||
/>
|
||||
<Button
|
||||
buttonType={ButtonType.PrimaryButton}
|
||||
className="flex-shrink-0"
|
||||
onClick={onAddClicked}
|
||||
text="Neues Rezept"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue