Integrate project overview filters and icon actions
Document filter combinations, reset and browser persistence. Keep the search hint applicable before board information fields are introduced. Validation: 20 focused tests, 431 total tests, API/web builds, scripts and Next typechecks, and diff checks passed. GUI retest skipped as authorized based on the colleague's prior testing.
This commit is contained in:
commit
2e223be8b4
11 changed files with 1457 additions and 156 deletions
120
src/frontend/components/filter-panel.tsx
Normal file
120
src/frontend/components/filter-panel.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
|
||||
import React, { useEffect, useId, useState, type ReactNode } from "react";
|
||||
import { IconButton } from "./icon-button";
|
||||
|
||||
function readCollapsed(storageKey: string) {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// Storage unavailable (private mode, blocked): keep the in-memory state.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible filter area inside a card (PM app style): a small header row
|
||||
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
|
||||
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
|
||||
* button while a filter is active. Only the bar collapses, never the card.
|
||||
* The collapsed state is remembered per `storageKey` (default: expanded).
|
||||
*/
|
||||
export function CollapsibleFilterPanel({
|
||||
storageKey,
|
||||
isFilterActive,
|
||||
visibleCount,
|
||||
totalCount,
|
||||
onReset,
|
||||
children,
|
||||
}: {
|
||||
storageKey: string;
|
||||
isFilterActive: boolean;
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
onReset: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const barId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
setIsCollapsed(readCollapsed(storageKey));
|
||||
}, [storageKey]);
|
||||
|
||||
function toggleCollapsed() {
|
||||
const next = !isCollapsed;
|
||||
setIsCollapsed(next);
|
||||
writeCollapsed(storageKey, next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-panel-header">
|
||||
<IconButton
|
||||
ariaControls={barId}
|
||||
ariaExpanded={!isCollapsed}
|
||||
className="filter-panel-toggle"
|
||||
icon={isCollapsed ? ChevronRight : ChevronDown}
|
||||
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
|
||||
onClick={toggleCollapsed}
|
||||
/>
|
||||
<span className="filter-panel-label">Filter</span>
|
||||
{isCollapsed && isFilterActive ? (
|
||||
<span className="filter-panel-badge">Filter aktiv</span>
|
||||
) : null}
|
||||
<span aria-live="polite" className="filter-panel-count">
|
||||
{visibleCount} von {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
{isCollapsed ? null : (
|
||||
<div className="filter-bar" id={barId}>
|
||||
{children}
|
||||
{isFilterActive ? (
|
||||
<IconButton
|
||||
className="filter-bar-reset"
|
||||
icon={FilterX}
|
||||
label="Zurücksetzen"
|
||||
onClick={onReset}
|
||||
showLabel
|
||||
title="Alle Filter zurücksetzen"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Search field for a filter bar. */
|
||||
export function FilterSearchField({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="filter-bar-field">
|
||||
<span className="filter-bar-label">{label}</span>
|
||||
<input
|
||||
className="filter-bar-search"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="search"
|
||||
value={value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import React, { type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Compact editor button with a lucide icon. `label` is always the accessible
|
||||
|
|
|
|||
40
src/frontend/components/icon-link.tsx
Normal file
40
src/frontend/components/icon-link.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Navigation counterpart of `IconButton`: a Next.js link with the same
|
||||
* look. `label` is always the accessible name and, unless `title` is given,
|
||||
* the tooltip; with `showLabel` it is also rendered next to the icon.
|
||||
*/
|
||||
export function IconLink({
|
||||
icon: Icon,
|
||||
label,
|
||||
title,
|
||||
href,
|
||||
variant = "default",
|
||||
showLabel = false,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
title?: string;
|
||||
href: string;
|
||||
variant?: "default" | "danger" | "primary";
|
||||
showLabel?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
aria-label={showLabel ? undefined : label}
|
||||
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
|
||||
href={href}
|
||||
title={title ?? label}
|
||||
>
|
||||
<Icon aria-hidden="true" size={15} strokeWidth={2} />
|
||||
{showLabel ? <span>{label}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
127
src/frontend/components/multi-select-dropdown.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
toggleFilterValue,
|
||||
type FilterOption,
|
||||
} from "../utils/overview-filters";
|
||||
|
||||
/**
|
||||
* Filter dropdown with a checkbox list (PM app style). An empty selection
|
||||
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
|
||||
* click and Escape.
|
||||
*/
|
||||
export function MultiSelectDropdown({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
emptyLabel = "Keine Einträge vorhanden",
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly FilterOption[];
|
||||
selected: readonly string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
const panelId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
toggleRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const summary =
|
||||
selected.length === 0
|
||||
? "Alle"
|
||||
: selected.length === 1
|
||||
? options.find((option) => option.id === selected[0])?.label ??
|
||||
"1 ausgewählt"
|
||||
: `${selected.length} ausgewählt`;
|
||||
|
||||
return (
|
||||
<div className="filter-dropdown" ref={containerRef}>
|
||||
<span className="filter-bar-label" id={labelId}>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
aria-controls={panelId}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
ref={toggleRef}
|
||||
title={summary}
|
||||
type="button"
|
||||
>
|
||||
<span className="filter-dropdown-value" id={valueId}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div
|
||||
aria-labelledby={labelId}
|
||||
className="filter-dropdown-panel"
|
||||
id={panelId}
|
||||
role="group"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div className="filter-dropdown-empty">{emptyLabel}</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="filter-dropdown-option filter-dropdown-option-all">
|
||||
<input
|
||||
checked={selected.length === 0}
|
||||
onChange={() => onChange([])}
|
||||
type="checkbox"
|
||||
/>
|
||||
Alle
|
||||
</label>
|
||||
{options.map((option) => (
|
||||
<label className="filter-dropdown-option" key={option.id}>
|
||||
<input
|
||||
checked={selected.includes(option.id)}
|
||||
onChange={() =>
|
||||
onChange(toggleFilterValue(selected, option.id))
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +28,14 @@ import {
|
|||
getProjectSnapshotRevisionDescription,
|
||||
mergeProjectRevisionPages,
|
||||
} from "../utils/project-version-history";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { IconButton, IconButtonSeparator } from "./icon-button";
|
||||
|
||||
interface ProjectVersionHistoryProps {
|
||||
projectId: string;
|
||||
|
|
@ -234,50 +242,50 @@ export function ProjectVersionHistory({
|
|||
<span className="badge text-bg-secondary">
|
||||
Aktuelle Revision {currentRevision}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.undoDepth
|
||||
}
|
||||
onClick={() => void handleHistoryCommand("undo")}
|
||||
title={
|
||||
history?.undoDepth
|
||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||
: "Keine Änderung zum Rückgängigmachen"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Rückgängig
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.redoDepth
|
||||
}
|
||||
onClick={() => void handleHistoryCommand("redo")}
|
||||
title={
|
||||
history?.redoDepth
|
||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||
: "Keine Änderung zum Wiederholen"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Wiederholen
|
||||
</button>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||
</button>
|
||||
<div className="overview-actions">
|
||||
<IconButton
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.undoDepth
|
||||
}
|
||||
icon={Undo2}
|
||||
label="Rückgängig"
|
||||
onClick={() => void handleHistoryCommand("undo")}
|
||||
showLabel
|
||||
title={
|
||||
history?.undoDepth
|
||||
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
|
||||
: "Keine Änderung zum Rückgängigmachen"
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={
|
||||
isBusy ||
|
||||
isHistoryLoading ||
|
||||
!historyIsCurrent ||
|
||||
!history?.redoDepth
|
||||
}
|
||||
icon={Redo2}
|
||||
label="Wiederholen"
|
||||
onClick={() => void handleHistoryCommand("redo")}
|
||||
showLabel
|
||||
title={
|
||||
history?.redoDepth
|
||||
? `${history.redoDepth} Änderung(en) können wiederholt werden`
|
||||
: "Keine Änderung zum Wiederholen"
|
||||
}
|
||||
/>
|
||||
<IconButtonSeparator />
|
||||
<IconButton
|
||||
ariaExpanded={isOpen}
|
||||
icon={isOpen ? ChevronUp : ChevronDown}
|
||||
label={isOpen ? "Ausblenden" : "Anzeigen"}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
|
|
@ -381,14 +389,15 @@ export function ProjectVersionHistory({
|
|||
</td>
|
||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||
<td className="text-end">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
disabled={isBusy}
|
||||
onClick={() => setRestoreCandidate(snapshot)}
|
||||
type="button"
|
||||
>
|
||||
Wiederherstellen
|
||||
</button>
|
||||
<div className="overview-actions justify-content-end">
|
||||
<IconButton
|
||||
disabled={isBusy}
|
||||
icon={RotateCcw}
|
||||
label="Wiederherstellen"
|
||||
onClick={() => setRestoreCandidate(snapshot)}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue