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 [currentSearchString, setCurrentSearchString] = useState<string>("")
|
||||||
|
|
||||||
const changeSearchString = (newSearchString: string) => {
|
const changeSearchString = (newSearchString: string) => {
|
||||||
console.log(newSearchString);
|
|
||||||
setCurrentSearchString(newSearchString);
|
setCurrentSearchString(newSearchString);
|
||||||
onSearchStringChanged(newSearchString)
|
onSearchStringChanged(newSearchString)
|
||||||
}
|
}
|
||||||
|
|
@ -27,41 +26,29 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea
|
||||||
const iconStyle: string = "text-gray-400 hover:text-gray-500";
|
const iconStyle: string = "text-gray-400 hover:text-gray-500";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx("relative", className)}>
|
||||||
"relative",
|
|
||||||
className)}>
|
|
||||||
{/* Input of searchfield
|
|
||||||
Defines border and behavior. Requires extra padding at both sides to
|
|
||||||
accommodate the icons
|
|
||||||
*/}
|
|
||||||
<input
|
<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"
|
type="text"
|
||||||
placeholder="Search"
|
placeholder="Suche"
|
||||||
value={currentSearchString}
|
value={currentSearchString}
|
||||||
onChange={e => changeSearchString(e.target.value)}
|
onChange={e => changeSearchString(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{/* Right icon: X
|
{/* Right icon: X — clears search string on click */}
|
||||||
Clears search string on click
|
|
||||||
*/}
|
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
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)}
|
iconStyle)}
|
||||||
onClick={() => changeSearchString("")}
|
onClick={() => changeSearchString("")}
|
||||||
>
|
>
|
||||||
<X
|
<X size={defaultIconSize}/>
|
||||||
size={defaultIconSize}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
{/* Left icon: Looking glass */}
|
{/* Left icon: looking glass */}
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"absolute left-0 inset-y-0 flex items-center ml-3",
|
"absolute left-0 inset-y-0 flex items-center ml-3",
|
||||||
iconStyle)}>
|
iconStyle)}>
|
||||||
<Search
|
<Search size={defaultIconSize}/>
|
||||||
size={defaultIconSize}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ export default function StickyHeader({children, className = ""}: StickyHeaderPro
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import {useEffect, useState} from "react"
|
||||||
import RecipeListItem from "./RecipeListItem"
|
import RecipeListItem from "./RecipeListItem"
|
||||||
import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts"
|
import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts"
|
||||||
import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.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 {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"
|
||||||
|
|
@ -20,14 +22,27 @@ export default function RecipeListPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [recipeList, setRecipeList] = useState<CompactRecipeDto[] | null>(null)
|
const [recipeList, setRecipeList] = useState<CompactRecipeDto[] | null>(null)
|
||||||
const [searchString, setSearchString] = useState<string>("")
|
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
|
// Load available tags once on mount to populate the filter dropdown
|
||||||
// avoid firing on every keystroke
|
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(() => {
|
useEffect(() => {
|
||||||
const timeout = setTimeout(async () => {
|
const timeout = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await fetchRecipeList(searchString, tagIdList)
|
const data = await fetchRecipeList(searchString, selectedTagIds)
|
||||||
setRecipeList(data)
|
setRecipeList(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -35,10 +50,13 @@ export default function RecipeListPage() {
|
||||||
}, SEARCH_DEBOUNCE_MS)
|
}, SEARCH_DEBOUNCE_MS)
|
||||||
|
|
||||||
return () => clearTimeout(timeout)
|
return () => clearTimeout(timeout)
|
||||||
}, [searchString, tagIdList])
|
}, [searchString, selectedTagIds])
|
||||||
|
|
||||||
const handleAdd = () => {
|
const tagFilterModel: FilterDropdownModel = {
|
||||||
navigate(getRecipeAddUrl())
|
placeholder: "Kategorie",
|
||||||
|
options: tagOptions,
|
||||||
|
selectedIds: selectedTagIds,
|
||||||
|
onSelectionChanged: setSelectedTagIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!recipeList) {
|
if (!recipeList) {
|
||||||
|
|
@ -54,9 +72,10 @@ export default function RecipeListPage() {
|
||||||
<StickyHeader>
|
<StickyHeader>
|
||||||
<h1>Recipes</h1>
|
<h1>Recipes</h1>
|
||||||
<RecipeListToolbar
|
<RecipeListToolbar
|
||||||
onAddClicked={handleAdd}
|
onAddClicked={() => navigate(getRecipeAddUrl())}
|
||||||
onSearchStringChanged={setSearchString}
|
onSearchStringChanged={setSearchString}
|
||||||
numberOfRecipes={recipeList.length}
|
numberOfRecipes={recipeList.length}
|
||||||
|
tagFilterModel={tagFilterModel}
|
||||||
/>
|
/>
|
||||||
</StickyHeader>
|
</StickyHeader>
|
||||||
{/* Content - List of recipe cards */}
|
{/* 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 Button from "../basics/Button"
|
||||||
import SearchField from "../basics/SearchField"
|
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 = {
|
type RecipeListToolbarProps = {
|
||||||
onSearchStringChanged: (searchString : string) => void
|
onSearchStringChanged: (searchString: string) => void
|
||||||
onAddClicked: () => 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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 w-full">
|
<div className="flex 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">
|
{/* Recipe count — left side, grows to push right-side group to the edge */}
|
||||||
<label className="label">{numberOfRecipes} Recipes</label>
|
<div className="flex-1">
|
||||||
|
<label className="label">{numberOfRecipes} Rezepte</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search + Add button container: right-aligned on medium+ screens */}
|
{/* Right-side group: wraps internally so Tags falls under Search+Button
|
||||||
<div className="order-1 md:order-2 flex flex-1 md:flex-none justify-end gap-2 min-w-[160px]">
|
and inherits exactly the same width */}
|
||||||
<div className="flex-1 md:flex-none md:max-w-[500px]">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<SearchField onSearchStringChanged={onSearchStringChanged} />
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
<Button
|
|
||||||
buttonType = {ButtonType.PrimaryButton}
|
{/* Search + Button — order-1 so they stay on row 1 when wrapping */}
|
||||||
className="flex-shrink-0"
|
<div className="order-1 md:order-2 flex gap-2 flex-shrink-0">
|
||||||
onClick={onAddClicked}
|
<SearchField
|
||||||
text = "Add recipe"
|
onSearchStringChanged={onSearchStringChanged}
|
||||||
/>
|
className="w-[260px]"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
buttonType={ButtonType.PrimaryButton}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
onClick={onAddClicked}
|
||||||
|
text="Neues Rezept"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue