mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
import { useEffect, useMemo } from "react";
|
|
import Avatar from "@/components/avatar";
|
|
import { useSelection } from "@/components/selection-provider";
|
|
import StatusTag from "@/components/status-tag";
|
|
import Icon, { type IconName } from "@/components/ui/icon";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
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);
|
|
const selectedOrganisation = useSelectedOrganisation();
|
|
const selectedProject = useSelectedProject();
|
|
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
|
|
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
|
|
string,
|
|
{ icon: string; color: string }
|
|
>;
|
|
|
|
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 "#";
|
|
const params = new URLSearchParams();
|
|
params.set("o", selectedOrganisation.Organisation.slug.toLowerCase());
|
|
params.set("p", selectedProject.Project.key.toLowerCase());
|
|
params.set("i", issueNumber.toString());
|
|
return `/issues?${params.toString()}`;
|
|
};
|
|
|
|
const handleLinkClick = (e: React.MouseEvent) => {
|
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
};
|
|
|
|
const showId = columns.id == null || columns.id === true;
|
|
const showTitle = columns.title == null || columns.title === true;
|
|
const showDescription = columns.description == null || columns.description === true;
|
|
const showAssignee = columns.assignee == null || columns.assignee === true;
|
|
|
|
return (
|
|
<Table className={cn("table-fixed", className)}>
|
|
<TableHeader>
|
|
<TableRow hoverEffect={false} className="bg-secondary">
|
|
{showId && (
|
|
<TableHead className="text-right w-10 border-r text-xs font-medium text-muted-foreground">
|
|
ID
|
|
</TableHead>
|
|
)}
|
|
{showTitle && <TableHead className="text-xs font-medium text-muted-foreground">Title</TableHead>}
|
|
{showDescription && (
|
|
<TableHead className="text-xs font-medium text-muted-foreground">Description</TableHead>
|
|
)}
|
|
{/* below is kept blank to fill the space, used as the "Assignee" column */}
|
|
{showAssignee && <TableHead className="w-[1%]"></TableHead>}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{issues.map((issueData) => {
|
|
const isSelected = issueData.Issue.id === selectedIssueId;
|
|
return (
|
|
<TableRow
|
|
key={issueData.Issue.id}
|
|
className={cn("cursor-pointer max-w-full")}
|
|
onClick={() => {
|
|
if (isSelected) {
|
|
selectIssue(null);
|
|
return;
|
|
}
|
|
selectIssue(issueData);
|
|
}}
|
|
>
|
|
{showId && (
|
|
<TableCell
|
|
className={cn(
|
|
"font-medium border-r text-right p-0",
|
|
isSelected &&
|
|
"shadow-[inset_2px_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
|
|
)}
|
|
>
|
|
<a
|
|
href={getIssueUrl(issueData.Issue.number)}
|
|
onClick={handleLinkClick}
|
|
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
|
>
|
|
{issueData.Issue.number.toString().padStart(3, "0")}
|
|
</a>
|
|
</TableCell>
|
|
)}
|
|
{showTitle && (
|
|
<TableCell
|
|
className={cn(
|
|
"min-w-0 p-0",
|
|
isSelected &&
|
|
"shadow-[inset_0_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
|
|
)}
|
|
>
|
|
<a
|
|
href={getIssueUrl(issueData.Issue.number)}
|
|
onClick={handleLinkClick}
|
|
className="flex items-center gap-2 min-w-0 w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
|
>
|
|
{selectedOrganisation?.Organisation.features.issueTypes &&
|
|
issueTypes[issueData.Issue.type] && (
|
|
<Icon
|
|
icon={issueTypes[issueData.Issue.type].icon as IconName}
|
|
size={16}
|
|
color={issueTypes[issueData.Issue.type].color}
|
|
/>
|
|
)}
|
|
{selectedOrganisation?.Organisation.features.issueStatus &&
|
|
(columns.status == null || columns.status === true) && (
|
|
<StatusTag
|
|
status={issueData.Issue.status}
|
|
colour={statuses[issueData.Issue.status]}
|
|
/>
|
|
)}
|
|
<span className="truncate">{issueData.Issue.title}</span>
|
|
</a>
|
|
</TableCell>
|
|
)}
|
|
{showDescription && (
|
|
<TableCell
|
|
className={cn(
|
|
"overflow-hidden p-0",
|
|
isSelected &&
|
|
"shadow-[inset_0_2px_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
|
|
)}
|
|
>
|
|
<a
|
|
href={getIssueUrl(issueData.Issue.number)}
|
|
onClick={handleLinkClick}
|
|
className="block w-full h-full px-2 py-1 text-inherit hover:underline decoration-transparent"
|
|
>
|
|
{issueData.Issue.description}
|
|
</a>
|
|
</TableCell>
|
|
)}
|
|
{showAssignee && (
|
|
<TableCell
|
|
className={cn(
|
|
"h-[32px] p-0",
|
|
isSelected &&
|
|
"shadow-[inset_0_2px_0_0_var(--personality),inset_-2px_0_0_0_var(--personality),inset_0_-2px_0_0_var(--personality)]",
|
|
)}
|
|
>
|
|
<a
|
|
href={getIssueUrl(issueData.Issue.number)}
|
|
onClick={handleLinkClick}
|
|
className="flex items-center justify-end w-full h-full px-2"
|
|
>
|
|
{selectedOrganisation?.Organisation.features.issueAssigneesShownInTable &&
|
|
issueData.Assignees &&
|
|
issueData.Assignees.length > 0 && (
|
|
<div className="flex items-center -space-x-2 pr-1.5">
|
|
{issueData.Assignees.slice(0, 3).map((assignee) => (
|
|
<Avatar
|
|
key={assignee.id}
|
|
name={assignee.name}
|
|
username={assignee.username}
|
|
avatarURL={assignee.avatarURL}
|
|
textClass="text-xs"
|
|
className="ring-1 ring-background"
|
|
/>
|
|
))}
|
|
{issueData.Assignees.length > 3 && (
|
|
<span className="flex items-center justify-center w-6 h-6 text-[10px] font-medium bg-muted text-muted-foreground rounded-full ring-1 ring-background">
|
|
+{issueData.Assignees.length - 3}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</a>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
);
|
|
}
|