issue filters

This commit is contained in:
2026-01-26 23:21:57 +00:00
parent a9adef7423
commit 3fc21e0350
2 changed files with 451 additions and 6 deletions

View File

@@ -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 "#";

View File

@@ -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<IssuesTableFilters>(() => 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 = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
const selectFallback = <T,>(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<string, string>;
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<IssuesTableFilters["sort"], string> = {
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 (
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
<TopBar />
{selectedOrganisationId && selectedProjectId && (
<div className={`flex flex-wrap gap-${BREATHING_ROOM} items-center`}>
<div className="w-64">
<Input
type="search"
value={issueFilters.query}
onChange={(event) => {
const nextQuery = event.target.value;
handleIssueFiltersChange((current) => ({
...current,
query: nextQuery,
}));
}}
placeholder="Search issues"
showCounter={false}
/>
</div>
{selectedOrganisation?.Organisation.features.issueStatus && (
<DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9">
Status
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.keys(statuses).length === 0 && (
<DropdownMenuItem disabled>No statuses</DropdownMenuItem>
)}
{Object.entries(statuses).map(([status, colour]) => (
<DropdownMenuCheckboxItem
key={status}
checked={issueFilters.statuses.includes(status)}
onCheckedChange={(checked) => {
handleIssueFiltersChange((current) => ({
...current,
statuses: checked
? Array.from(new Set([...current.statuses, status]))
: current.statuses.filter((item) => item !== status),
}));
}}
>
<StatusTag status={status} colour={colour} />
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{selectedOrganisation?.Organisation.features.issueTypes && (
<DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9">
Type
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Type</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.keys(issueTypes).length === 0 && (
<DropdownMenuItem disabled>No types</DropdownMenuItem>
)}
{Object.entries(issueTypes).map(([type, definition]) => (
<DropdownMenuCheckboxItem
key={type}
checked={issueFilters.types.includes(type)}
onCheckedChange={(checked) => {
handleIssueFiltersChange((current) => ({
...current,
types: checked
? Array.from(new Set([...current.types, type]))
: current.types.filter((item) => item !== type),
}));
}}
>
<div className="flex items-center gap-2">
<Icon icon={definition.icon} size={14} color={definition.color} />
<span>{type}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9">
Assignee
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Assignee</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={issueFilters.assignees.includes("unassigned")}
onCheckedChange={(checked) => {
handleIssueFiltersChange((current) => ({
...current,
assignees: checked
? Array.from(new Set([...current.assignees, "unassigned"]))
: current.assignees.filter((item) => item !== "unassigned"),
}));
}}
>
Unassigned
</DropdownMenuCheckboxItem>
{members.length === 0 && <DropdownMenuItem disabled>No members</DropdownMenuItem>}
{members.map((member) => (
<DropdownMenuCheckboxItem
key={member.id}
checked={issueFilters.assignees.includes(String(member.id))}
onCheckedChange={(checked) => {
handleIssueFiltersChange((current) => ({
...current,
assignees: checked
? Array.from(new Set([...current.assignees, String(member.id)]))
: current.assignees.filter((item) => item !== String(member.id)),
}));
}}
>
<SmallUserDisplay user={member} />
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{selectedOrganisation?.Organisation.features.sprints && (
<DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9">
{sprintLabel}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Sprint</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={String(issueFilters.sprintId)}
onValueChange={(value) => {
handleIssueFiltersChange((current) => ({
...current,
sprintId:
value === "all" ? "all" : value === "none" ? "none" : Number.parseInt(value, 10),
}));
}}
>
<DropdownMenuRadioItem value="all">All sprints</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="none">No sprint</DropdownMenuRadioItem>
{sprintsData.map((sprint) => (
<DropdownMenuRadioItem key={sprint.id} value={String(sprint.id)}>
{sprint.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger size="default" className="h-9">
Sort: {sortLabels[issueFilters.sort]}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>Sort</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={issueFilters.sort}
onValueChange={(value) => {
handleIssueFiltersChange((current) => ({
...current,
sort: value as IssuesTableFilters["sort"],
}));
}}
>
<DropdownMenuRadioItem value="newest">Newest</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="oldest">Oldest</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-asc">Title A-Z</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="title-desc">Title Z-A</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="status">Status</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<IconButton
variant="outline"
className="w-9 h-9"
disabled={
!issueFilters.query &&
issueFilters.statuses.length === 0 &&
issueFilters.types.length === 0 &&
issueFilters.assignees.length === 0 &&
issueFilters.sprintId === defaultIssuesTableFilters.sprintId &&
issueFilters.sort === defaultIssuesTableFilters.sort
}
aria-label="Clear filters"
title="Clear filters"
onClick={() => {
handleIssueFiltersChange({ ...defaultIssuesTableFilters });
}}
>
<Icon icon="undo" />
</IconButton>
</div>
)}
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
<ResizablePanelGroup className={`flex-1`}>
<ResizablePanel id={"left"} minSize={400}>
<div className="border w-full flex-shrink">
<IssuesTable columns={{ description: false }} className="w-full" />
<IssuesTable columns={{ description: false }} className="w-full" filters={issueFilters} />
</div>
</ResizablePanel>