From 3fc21e0350e63bc9a50d4568e58d01743266f7c8 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Mon, 26 Jan 2026 23:21:57 +0000 Subject: [PATCH] issue filters --- .../frontend/src/components/issues-table.tsx | 108 +++++- packages/frontend/src/pages/Issues.tsx | 349 +++++++++++++++++- 2 files changed, 451 insertions(+), 6 deletions(-) diff --git a/packages/frontend/src/components/issues-table.tsx b/packages/frontend/src/components/issues-table.tsx index 3dd57bb..bd8c4a7 100644 --- a/packages/frontend/src/components/issues-table.tsx +++ b/packages/frontend/src/components/issues-table.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import Avatar from "@/components/avatar"; import { useSelection } from "@/components/selection-provider"; import StatusTag from "@/components/status-tag"; @@ -7,12 +7,32 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks"; import { cn } from "@/lib/utils"; +export type IssuesTableFilters = { + query: string; + statuses: string[]; + types: string[]; + assignees: string[]; + sprintId: "all" | "none" | number; + sort: "newest" | "oldest" | "title-asc" | "title-desc" | "status"; +}; + +export const defaultIssuesTableFilters: IssuesTableFilters = { + query: "", + statuses: [], + types: [], + assignees: [], + sprintId: "all", + sort: "newest", +}; + export function IssuesTable({ columns = {}, className, + filters, }: { columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean }; className: string; + filters?: IssuesTableFilters; }) { const { selectedProjectId, selectedIssueId, selectIssue } = useSelection(); const { data: issuesData = [] } = useIssues(selectedProjectId); @@ -24,7 +44,91 @@ export function IssuesTable({ { icon: string; color: string } >; - const issues = useMemo(() => [...issuesData].reverse(), [issuesData]); + const issues = useMemo(() => { + const query = filters?.query?.trim().toLowerCase() ?? ""; + const queryIsNumber = query !== "" && /^[0-9]+$/.test(query); + const statusSet = new Set(filters?.statuses ?? []); + const typeSet = new Set(filters?.types ?? []); + const assigneeFilters = filters?.assignees ?? []; + const includeUnassigned = assigneeFilters.includes("unassigned"); + const assigneeIds = new Set( + assigneeFilters + .filter((assignee) => assignee !== "unassigned") + .map((assignee) => Number.parseInt(assignee, 10)) + .filter((assigneeId) => !Number.isNaN(assigneeId)), + ); + const sprintFilter = filters?.sprintId ?? "all"; + const sort = filters?.sort ?? "newest"; + + let next = [...issuesData]; + + if (query) { + next = next.filter((issueData) => { + const title = issueData.Issue.title.toLowerCase(); + const description = issueData.Issue.description.toLowerCase(); + const matchesText = title.includes(query) || description.includes(query); + if (matchesText) return true; + if (queryIsNumber) { + return issueData.Issue.number.toString().includes(query); + } + return false; + }); + } + + if (statusSet.size > 0) { + next = next.filter((issueData) => statusSet.has(issueData.Issue.status)); + } + + if (typeSet.size > 0) { + next = next.filter((issueData) => typeSet.has(issueData.Issue.type)); + } + + if (assigneeFilters.length > 0) { + next = next.filter((issueData) => { + const hasAssignees = issueData.Assignees && issueData.Assignees.length > 0; + const matchesAssigned = + hasAssignees && issueData.Assignees.some((assignee) => assigneeIds.has(assignee.id)); + const matchesUnassigned = includeUnassigned && !hasAssignees; + return matchesAssigned || matchesUnassigned; + }); + } + + if (sprintFilter !== "all") { + if (sprintFilter === "none") { + next = next.filter((issueData) => issueData.Issue.sprintId == null); + } else { + next = next.filter((issueData) => issueData.Issue.sprintId === sprintFilter); + } + } + + switch (sort) { + case "oldest": + next.sort((a, b) => a.Issue.number - b.Issue.number); + break; + case "title-asc": + next.sort((a, b) => a.Issue.title.localeCompare(b.Issue.title)); + break; + case "title-desc": + next.sort((a, b) => b.Issue.title.localeCompare(a.Issue.title)); + break; + case "status": + next.sort((a, b) => a.Issue.status.localeCompare(b.Issue.status)); + break; + default: + next.sort((a, b) => b.Issue.number - a.Issue.number); + break; + } + + return next; + }, [issuesData, filters]); + + useEffect(() => { + if (selectedIssueId == null) return; + const isVisible = issues.some((issueData) => issueData.Issue.id === selectedIssueId); + if (!isVisible) { + selectIssue(null); + } + }, [issues, selectedIssueId, selectIssue]); const getIssueUrl = (issueNumber: number) => { if (!selectedOrganisation || !selectedProject) return "#"; diff --git a/packages/frontend/src/pages/Issues.tsx b/packages/frontend/src/pages/Issues.tsx index 4d95b85..ba487a9 100644 --- a/packages/frontend/src/pages/Issues.tsx +++ b/packages/frontend/src/pages/Issues.tsx @@ -1,15 +1,94 @@ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueModal } from "@/components/issue-modal"; -import { IssuesTable } from "@/components/issues-table"; +import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table"; import { useSelection } from "@/components/selection-provider"; +import SmallUserDisplay from "@/components/small-user-display"; +import StatusTag from "@/components/status-tag"; import TopBar from "@/components/top-bar"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import Icon, { type IconName } from "@/components/ui/icon"; +import { IconButton } from "@/components/ui/icon-button"; +import { Input } from "@/components/ui/input"; import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable"; import { BREATHING_ROOM } from "@/lib/layout"; -import { useIssues, useOrganisations, useProjects, useSelectedIssue } from "@/lib/query/hooks"; +import { + useIssues, + useOrganisationMembers, + useOrganisations, + useProjects, + useSelectedIssue, + useSelectedOrganisation, + useSprints, +} from "@/lib/query/hooks"; + +const parseListParam = (value: string | null) => + value + ? value + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : []; + +const parseIssueFilters = (search: string): IssuesTableFilters => { + const params = new URLSearchParams(search); + const query = params.get("q")?.trim() ?? ""; + const statuses = parseListParam(params.get("status")); + const types = parseListParam(params.get("type")); + const assignees = parseListParam(params.get("assignee")); + const sprintParam = params.get("sprint")?.trim().toLowerCase() ?? ""; + const sortParam = params.get("sort")?.trim().toLowerCase() ?? ""; + + let sprintId: IssuesTableFilters["sprintId"] = "all"; + if (sprintParam === "none") { + sprintId = "none"; + } else if (sprintParam !== "") { + const parsedSprintId = Number.parseInt(sprintParam, 10); + sprintId = Number.isNaN(parsedSprintId) ? "all" : parsedSprintId; + } + + const sortValues: IssuesTableFilters["sort"][] = ["newest", "oldest", "title-asc", "title-desc", "status"]; + const sort = sortValues.includes(sortParam as IssuesTableFilters["sort"]) + ? (sortParam as IssuesTableFilters["sort"]) + : "newest"; + + return { + ...defaultIssuesTableFilters, + query, + statuses, + types, + assignees, + sprintId, + sort, + }; +}; + +const filtersEqual = (left: IssuesTableFilters, right: IssuesTableFilters) => { + if (left.query !== right.query) return false; + if (left.sprintId !== right.sprintId) return false; + if (left.sort !== right.sort) return false; + if (left.statuses.length !== right.statuses.length) return false; + if (left.types.length !== right.types.length) return false; + if (left.assignees.length !== right.assignees.length) return false; + return ( + left.statuses.every((status) => right.statuses.includes(status)) && + left.types.every((type) => right.types.includes(type)) && + left.assignees.every((assignee) => right.assignees.includes(assignee)) + ); +}; export default function Issues() { const { @@ -43,6 +122,11 @@ export default function Issues() { const { data: projectsData = [] } = useProjects(selectedOrganisationId); const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId); const selectedIssue = useSelectedIssue(); + const selectedOrganisation = useSelectedOrganisation(); + const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId); + const { data: sprintsData = [] } = useSprints(selectedProjectId); + const parsedFilters = useMemo(() => parseIssueFilters(location.search), [location.search]); + const [issueFilters, setIssueFilters] = useState(() => parsedFilters); const organisations = useMemo( () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), @@ -53,6 +137,40 @@ export default function Issues() { [projectsData], ); + useEffect(() => { + setIssueFilters((current) => (filtersEqual(current, parsedFilters) ? current : parsedFilters)); + }, [parsedFilters]); + + useEffect(() => { + const currentParams = new URLSearchParams(location.search); + const nextParams = new URLSearchParams(location.search); + + if (issueFilters.query) nextParams.set("q", issueFilters.query); + else nextParams.delete("q"); + + if (issueFilters.statuses.length > 0) nextParams.set("status", issueFilters.statuses.join(",")); + else nextParams.delete("status"); + + if (issueFilters.types.length > 0) nextParams.set("type", issueFilters.types.join(",")); + else nextParams.delete("type"); + + if (issueFilters.assignees.length > 0) nextParams.set("assignee", issueFilters.assignees.join(",")); + else nextParams.delete("assignee"); + + if (issueFilters.sprintId === "none") nextParams.set("sprint", "none"); + else if (issueFilters.sprintId !== "all") nextParams.set("sprint", String(issueFilters.sprintId)); + else nextParams.delete("sprint"); + + if (issueFilters.sort !== defaultIssuesTableFilters.sort) nextParams.set("sort", issueFilters.sort); + else nextParams.delete("sort"); + + if (currentParams.toString() === nextParams.toString()) return; + + const search = nextParams.toString(); + const nextUrl = `${location.pathname}${search ? `?${search}` : ""}`; + window.history.replaceState(null, "", nextUrl); + }, [issueFilters, location.pathname, location.search]); + const findById = (items: T[], id: number | null | undefined, getId: (item: T) => number) => id == null ? null : (items.find((item) => getId(item) === id) ?? null); const selectFallback = (items: T[], selected: T | null) => selected ?? items[0] ?? null; @@ -162,15 +280,238 @@ export default function Issues() { flow.stage = "done"; }, [deepLinkActive, issuesData, issuesFetched, selectedIssueId, selectedProjectId, selectIssue]); + const handleIssueFiltersChange = ( + next: IssuesTableFilters | ((current: IssuesTableFilters) => IssuesTableFilters), + ) => { + setIssueFilters((current) => (typeof next === "function" ? next(current) : next)); + }; + + const statuses = (selectedOrganisation?.Organisation.statuses ?? {}) as Record; + const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record< + string, + { icon: IconName; color: string } + >; + const members = useMemo( + () => [...membersData].map((member) => member.User).sort((a, b) => a.name.localeCompare(b.name)), + [membersData], + ); + const sortLabels: Record = { + newest: "Newest", + oldest: "Oldest", + "title-asc": "Title A-Z", + "title-desc": "Title Z-A", + status: "Status", + }; + const sprintLabel = useMemo(() => { + if (issueFilters.sprintId === "all") return "Sprint"; + if (issueFilters.sprintId === "none") return "No sprint"; + const sprintMatch = sprintsData.find((sprint) => sprint.id === issueFilters.sprintId); + return sprintMatch?.name ?? "Sprint"; + }, [issueFilters.sprintId, sprintsData]); + return (
+ {selectedOrganisationId && selectedProjectId && ( +
+
+ { + const nextQuery = event.target.value; + handleIssueFiltersChange((current) => ({ + ...current, + query: nextQuery, + })); + }} + placeholder="Search issues" + showCounter={false} + /> +
+ {selectedOrganisation?.Organisation.features.issueStatus && ( + + + Status + + + Status + + {Object.keys(statuses).length === 0 && ( + No statuses + )} + {Object.entries(statuses).map(([status, colour]) => ( + { + handleIssueFiltersChange((current) => ({ + ...current, + statuses: checked + ? Array.from(new Set([...current.statuses, status])) + : current.statuses.filter((item) => item !== status), + })); + }} + > + + + ))} + + + )} + {selectedOrganisation?.Organisation.features.issueTypes && ( + + + Type + + + Type + + {Object.keys(issueTypes).length === 0 && ( + No types + )} + {Object.entries(issueTypes).map(([type, definition]) => ( + { + handleIssueFiltersChange((current) => ({ + ...current, + types: checked + ? Array.from(new Set([...current.types, type])) + : current.types.filter((item) => item !== type), + })); + }} + > +
+ + {type} +
+
+ ))} +
+
+ )} + + + Assignee + + + Assignee + + { + handleIssueFiltersChange((current) => ({ + ...current, + assignees: checked + ? Array.from(new Set([...current.assignees, "unassigned"])) + : current.assignees.filter((item) => item !== "unassigned"), + })); + }} + > + Unassigned + + {members.length === 0 && No members} + {members.map((member) => ( + { + handleIssueFiltersChange((current) => ({ + ...current, + assignees: checked + ? Array.from(new Set([...current.assignees, String(member.id)])) + : current.assignees.filter((item) => item !== String(member.id)), + })); + }} + > + + + ))} + + + {selectedOrganisation?.Organisation.features.sprints && ( + + + {sprintLabel} + + + Sprint + + { + handleIssueFiltersChange((current) => ({ + ...current, + sprintId: + value === "all" ? "all" : value === "none" ? "none" : Number.parseInt(value, 10), + })); + }} + > + All sprints + No sprint + {sprintsData.map((sprint) => ( + + {sprint.name} + + ))} + + + + )} + + + Sort: {sortLabels[issueFilters.sort]} + + + Sort + + { + handleIssueFiltersChange((current) => ({ + ...current, + sort: value as IssuesTableFilters["sort"], + })); + }} + > + Newest + Oldest + Title A-Z + Title Z-A + Status + + + + { + handleIssueFiltersChange({ ...defaultIssuesTableFilters }); + }} + > + + +
+ )} + {selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
- +