mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +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 { 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 "#";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user