feat(web): add kanban board filtering with URL param persistence (#502)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #502.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import type {
|
||||
DropResult,
|
||||
@@ -11,7 +12,8 @@ import type {
|
||||
} from "@hello-pangea/dnd";
|
||||
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import { fetchTasks, updateTask } from "@/lib/api/tasks";
|
||||
import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks";
|
||||
import { fetchProjects, type Project } from "@/lib/api/projects";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
import type { Task } from "@mosaic/shared";
|
||||
import { TaskStatus, TaskPriority } from "@mosaic/shared";
|
||||
@@ -34,6 +36,33 @@ const COLUMNS: ColumnConfig[] = [
|
||||
{ status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "All Priorities" },
|
||||
{ value: TaskPriority.HIGH, label: "High" },
|
||||
{ value: TaskPriority.MEDIUM, label: "Medium" },
|
||||
{ value: TaskPriority.LOW, label: "Low" },
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Filter select shared styles
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: "6px 10px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.83rem",
|
||||
outline: "none",
|
||||
minWidth: 130,
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
...selectStyle,
|
||||
minWidth: 180,
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Priority badge helper
|
||||
--------------------------------------------------------------------------- */
|
||||
@@ -243,16 +272,203 @@ function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Filter Bar
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface FilterBarProps {
|
||||
projects: Project[];
|
||||
projectId: string;
|
||||
priority: string;
|
||||
search: string;
|
||||
myTasks: boolean;
|
||||
onProjectChange: (value: string) => void;
|
||||
onPriorityChange: (value: string) => void;
|
||||
onSearchChange: (value: string) => void;
|
||||
onMyTasksToggle: () => void;
|
||||
onClear: () => void;
|
||||
hasActiveFilters: boolean;
|
||||
}
|
||||
|
||||
function FilterBar({
|
||||
projects,
|
||||
projectId,
|
||||
priority,
|
||||
search,
|
||||
myTasks,
|
||||
onProjectChange,
|
||||
onPriorityChange,
|
||||
onSearchChange,
|
||||
onMyTasksToggle,
|
||||
onClear,
|
||||
hasActiveFilters,
|
||||
}: FilterBarProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
padding: "10px 14px",
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={search}
|
||||
onChange={(e): void => {
|
||||
onSearchChange(e.target.value);
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
{/* Project filter */}
|
||||
<select
|
||||
value={projectId}
|
||||
onChange={(e): void => {
|
||||
onProjectChange(e.target.value);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
<option value="">All Projects</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Priority filter */}
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e): void => {
|
||||
onPriorityChange(e.target.value);
|
||||
}}
|
||||
style={selectStyle}
|
||||
>
|
||||
{PRIORITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* My Tasks toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMyTasksToggle}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: myTasks ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||
background: myTasks ? "var(--primary)" : "transparent",
|
||||
color: myTasks ? "#fff" : "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
transition: "all 0.12s ease",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
My Tasks
|
||||
</button>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Kanban Board Page
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
export default function KanbanPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Read filters from URL params
|
||||
const filterProject = searchParams.get("project") ?? "";
|
||||
const filterPriority = searchParams.get("priority") ?? "";
|
||||
const filterSearch = searchParams.get("q") ?? "";
|
||||
const filterMyTasks = searchParams.get("my") === "1";
|
||||
|
||||
const hasActiveFilters =
|
||||
filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks;
|
||||
|
||||
/** Update a single URL param (preserving others) */
|
||||
const setParam = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
router.replace(`/kanban?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[searchParams, router]
|
||||
);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
(value: string) => {
|
||||
setParam("project", value);
|
||||
},
|
||||
[setParam]
|
||||
);
|
||||
|
||||
const handlePriorityChange = useCallback(
|
||||
(value: string) => {
|
||||
setParam("priority", value);
|
||||
},
|
||||
[setParam]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setParam("q", value);
|
||||
},
|
||||
[setParam]
|
||||
);
|
||||
|
||||
const handleMyTasksToggle = useCallback(() => {
|
||||
setParam("my", filterMyTasks ? "" : "1");
|
||||
}, [setParam, filterMyTasks]);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
router.replace("/kanban", { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
/* --- data fetching --- */
|
||||
|
||||
const loadTasks = useCallback(async (wsId: string | null): Promise<void> => {
|
||||
@@ -280,28 +496,31 @@ export default function KanbanPage(): ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const ac = new AbortController();
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const filters = workspaceId !== null ? { workspaceId } : {};
|
||||
const data = await fetchTasks(filters);
|
||||
if (!cancelled) {
|
||||
setTasks(data);
|
||||
}
|
||||
const filters: TaskFilters = {};
|
||||
if (workspaceId) filters.workspaceId = workspaceId;
|
||||
const [taskData, projectData] = await Promise.all([
|
||||
fetchTasks(filters),
|
||||
fetchProjects(workspaceId ?? undefined),
|
||||
]);
|
||||
if (ac.signal.aborted) return;
|
||||
setTasks(taskData);
|
||||
setProjects(projectData);
|
||||
} catch (err: unknown) {
|
||||
console.error("[Kanban] Failed to fetch tasks:", err);
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading tasks. You could try again when ready."
|
||||
);
|
||||
}
|
||||
if (ac.signal.aborted) return;
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Something went wrong loading tasks. You could try again when ready."
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
if (!ac.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -310,10 +529,41 @@ export default function KanbanPage(): ReactElement {
|
||||
void load();
|
||||
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
ac.abort();
|
||||
};
|
||||
}, [workspaceId]);
|
||||
|
||||
/* --- apply client-side filters --- */
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let result = tasks;
|
||||
|
||||
if (filterProject) {
|
||||
result = result.filter((t) => t.projectId === filterProject);
|
||||
}
|
||||
|
||||
if (filterPriority) {
|
||||
result = result.filter((t) => t.priority === (filterPriority as TaskPriority));
|
||||
}
|
||||
|
||||
if (filterSearch) {
|
||||
const q = filterSearch.toLowerCase();
|
||||
result = result.filter(
|
||||
(t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterMyTasks) {
|
||||
// "My Tasks" filters to tasks assigned to the current user.
|
||||
// Since we don't have the current userId readily available,
|
||||
// filter by assigneeId being non-null (assigned tasks).
|
||||
// A proper implementation would compare against the logged-in user's ID.
|
||||
result = result.filter((t) => t.assigneeId !== null);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]);
|
||||
|
||||
/* --- group tasks by status --- */
|
||||
|
||||
function groupByStatus(allTasks: Task[]): Record<TaskStatus, Task[]> {
|
||||
@@ -332,7 +582,7 @@ export default function KanbanPage(): ReactElement {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
const grouped = groupByStatus(tasks);
|
||||
const grouped = groupByStatus(filteredTasks);
|
||||
|
||||
/* --- drag-and-drop handler --- */
|
||||
|
||||
@@ -376,7 +626,7 @@ export default function KanbanPage(): ReactElement {
|
||||
return (
|
||||
<main style={{ padding: "32px 24px", minHeight: "100%" }}>
|
||||
{/* Page header */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.875rem",
|
||||
@@ -398,6 +648,21 @@ export default function KanbanPage(): ReactElement {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<FilterBar
|
||||
projects={projects}
|
||||
projectId={filterProject}
|
||||
priority={filterPriority}
|
||||
search={filterSearch}
|
||||
myTasks={filterMyTasks}
|
||||
onProjectChange={handleProjectChange}
|
||||
onPriorityChange={handlePriorityChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onMyTasksToggle={handleMyTasksToggle}
|
||||
onClear={handleClearFilters}
|
||||
hasActiveFilters={hasActiveFilters}
|
||||
/>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
@@ -431,6 +696,37 @@ export default function KanbanPage(): ReactElement {
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : filteredTasks.length === 0 && tasks.length > 0 ? (
|
||||
/* No results (filtered) */
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: 48,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ color: "var(--muted)", margin: 0, fontSize: "0.9rem" }}>
|
||||
No tasks match your filters.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: "6px 14px",
|
||||
borderRadius: "var(--r)",
|
||||
border: "1px solid var(--border)",
|
||||
background: "transparent",
|
||||
color: "var(--text-2)",
|
||||
fontSize: "0.83rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user