mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
issue filters
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import { useSelection } from "@/components/selection-provider";
|
import { useSelection } from "@/components/selection-provider";
|
||||||
import StatusTag from "@/components/status-tag";
|
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 { useIssues, useSelectedOrganisation, useSelectedProject } from "@/lib/query/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
export function IssuesTable({
|
||||||
columns = {},
|
columns = {},
|
||||||
className,
|
className,
|
||||||
|
filters,
|
||||||
}: {
|
}: {
|
||||||
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
||||||
className: string;
|
className: string;
|
||||||
|
filters?: IssuesTableFilters;
|
||||||
}) {
|
}) {
|
||||||
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
|
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
|
||||||
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
||||||
@@ -24,7 +44,91 @@ export function IssuesTable({
|
|||||||
{ icon: string; color: string }
|
{ 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) => {
|
const getIssueUrl = (issueNumber: number) => {
|
||||||
if (!selectedOrganisation || !selectedProject) return "#";
|
if (!selectedOrganisation || !selectedProject) return "#";
|
||||||
|
|||||||
@@ -1,15 +1,94 @@
|
|||||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
/** 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 { useLocation } from "react-router-dom";
|
||||||
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
||||||
import { IssueModal } from "@/components/issue-modal";
|
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 { 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 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 { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
||||||
import { BREATHING_ROOM } from "@/lib/layout";
|
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() {
|
export default function Issues() {
|
||||||
const {
|
const {
|
||||||
@@ -43,6 +122,11 @@ export default function Issues() {
|
|||||||
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||||
const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId);
|
const { data: issuesData = [], isFetched: issuesFetched } = useIssues(selectedProjectId);
|
||||||
const selectedIssue = useSelectedIssue();
|
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(
|
const organisations = useMemo(
|
||||||
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||||
@@ -53,6 +137,40 @@ export default function Issues() {
|
|||||||
[projectsData],
|
[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) =>
|
const findById = <T,>(items: T[], id: number | null | undefined, getId: (item: T) => number) =>
|
||||||
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
id == null ? null : (items.find((item) => getId(item) === id) ?? null);
|
||||||
const selectFallback = <T,>(items: T[], selected: T | null) => selected ?? items[0] ?? null;
|
const selectFallback = <T,>(items: T[], selected: T | null) => selected ?? items[0] ?? null;
|
||||||
@@ -162,15 +280,238 @@ export default function Issues() {
|
|||||||
flow.stage = "done";
|
flow.stage = "done";
|
||||||
}, [deepLinkActive, issuesData, issuesFetched, selectedIssueId, selectedProjectId, selectIssue]);
|
}, [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 (
|
return (
|
||||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
||||||
<TopBar />
|
<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 && (
|
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
|
||||||
<ResizablePanelGroup className={`flex-1`}>
|
<ResizablePanelGroup className={`flex-1`}>
|
||||||
<ResizablePanel id={"left"} minSize={400}>
|
<ResizablePanel id={"left"} minSize={400}>
|
||||||
<div className="border w-full flex-shrink">
|
<div className="border w-full flex-shrink">
|
||||||
<IssuesTable columns={{ description: false }} className="w-full" />
|
<IssuesTable columns={{ description: false }} className="w-full" filters={issueFilters} />
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user