/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import Avatar from "@/components/avatar"; import { Chat } from "@/components/chat"; import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueModal } from "@/components/issue-modal"; import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table"; import { useSelection } from "@/components/selection-provider"; import SmallSprintDisplay from "@/components/small-sprint-display"; 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, 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 getFilterStorageKey = (organisationId: number | null, projectId: number | null) => { if (!organisationId || !projectId) return null; return `sprint.issue-filters.${organisationId}.${projectId}`; }; const FILTER_PARAM_KEYS = ["q", "status", "type", "assignee", "sprint", "sort"] as const; const hasFilterParams = (search: string) => { const params = new URLSearchParams(search); return FILTER_PARAM_KEYS.some((key) => params.has(key)); }; const readStoredFilters = (storageKey: string): IssuesTableFilters | null => { try { const raw = localStorage.getItem(storageKey); if (!raw) return null; const parsed = JSON.parse(raw) as Partial | null; if (!parsed || typeof parsed !== "object") return null; const statuses = Array.isArray(parsed.statuses) ? parsed.statuses.filter(Boolean) : []; const types = Array.isArray(parsed.types) ? parsed.types.filter(Boolean) : []; const assignees = Array.isArray(parsed.assignees) ? parsed.assignees.filter(Boolean) : []; const query = typeof parsed.query === "string" ? parsed.query : ""; let sprintId: IssuesTableFilters["sprintId"] = "all"; if (parsed.sprintId === "none" || parsed.sprintId === "all") { sprintId = parsed.sprintId; } else if (typeof parsed.sprintId === "number" && !Number.isNaN(parsed.sprintId)) { sprintId = parsed.sprintId; } const sortValues: IssuesTableFilters["sort"][] = [ "newest", "oldest", "title-asc", "title-desc", "status", ]; const sort = sortValues.includes(parsed.sort as IssuesTableFilters["sort"]) ? (parsed.sort as IssuesTableFilters["sort"]) : defaultIssuesTableFilters.sort; return { ...defaultIssuesTableFilters, query, statuses, types, assignees, sprintId, sort, }; } catch { return null; } }; const writeStoredFilters = (storageKey: string, filters: IssuesTableFilters) => { try { localStorage.setItem(storageKey, JSON.stringify(filters)); } catch { return; } }; 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 { selectedOrganisationId, selectedProjectId, selectedIssueId, selectOrganisation, selectProject, selectIssue, } = useSelection(); const location = useLocation(); const [highlighted, setHighlighted] = useState([]); const deepLinkParams = useMemo(() => { const params = new URLSearchParams(location.search); const orgSlug = params.get("o")?.trim().toLowerCase() ?? ""; const projectKey = params.get("p")?.trim().toLowerCase() ?? ""; const issueParam = params.get("i")?.trim() ?? ""; const issueNumber = issueParam === "" ? null : Number.parseInt(issueParam, 10); return { orgSlug, projectKey, issueNumber: issueNumber != null && Number.isNaN(issueNumber) ? null : issueNumber, }; }, [location.search]); const showIssueModal = new URLSearchParams(window.location.search).get("modal")?.trim().toLowerCase() === "true"; const { data: organisationsData = [] } = useOrganisations(); 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 filterStorageKey = useMemo( () => getFilterStorageKey(selectedOrganisationId, selectedProjectId), [selectedOrganisationId, selectedProjectId], ); const filterParamsPresent = useMemo(() => hasFilterParams(location.search), [location.search]); const storedFilters = useMemo(() => { if (filterParamsPresent || !filterStorageKey) return null; return readStoredFilters(filterStorageKey); }, [filterParamsPresent, filterStorageKey]); const nextFilters = useMemo(() => { if (filterParamsPresent) return parseIssueFilters(location.search); if (storedFilters) return storedFilters; return defaultIssuesTableFilters; }, [filterParamsPresent, location.search, storedFilters]); const [issueFilters, setIssueFilters] = useState(() => nextFilters); const organisations = useMemo( () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), [organisationsData], ); const projects = useMemo( () => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)), [projectsData], ); useEffect(() => { setIssueFilters((current) => (filtersEqual(current, nextFilters) ? current : nextFilters)); }, [nextFilters]); useEffect(() => { if (!filterStorageKey) return; writeStoredFilters(filterStorageKey, issueFilters); }, [filterStorageKey, issueFilters]); 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; const findOrgBySlug = (slug: string) => organisations.find((org) => org.Organisation.slug.toLowerCase() === slug) ?? null; const findProjectByKey = (key: string) => projects.find((project) => project.Project.key.toLowerCase() === key) ?? null; const deepLinkActive = deepLinkParams.projectKey !== "" || deepLinkParams.issueNumber != null; const deepLinkFlowRef = useRef({ stage: "idle" as "idle" | "org" | "project" | "issue" | "done", orgSlug: "", projectKey: "", issueNumber: null as number | null, targetOrgId: null as number | null, targetProjectId: null as number | null, }); useEffect(() => { deepLinkFlowRef.current = { stage: deepLinkActive ? "org" : "idle", orgSlug: deepLinkParams.orgSlug, projectKey: deepLinkParams.projectKey, issueNumber: deepLinkParams.issueNumber, targetOrgId: null, targetProjectId: null, }; }, [deepLinkActive, deepLinkParams.orgSlug, deepLinkParams.projectKey, deepLinkParams.issueNumber]); useEffect(() => { if (organisations.length === 0) return; if (deepLinkActive && deepLinkFlowRef.current.stage !== "org") { return; } let selected = findById(organisations, selectedOrganisationId, (org) => org.Organisation.id); if (deepLinkActive && deepLinkFlowRef.current.orgSlug) { selected = findOrgBySlug(deepLinkFlowRef.current.orgSlug) ?? selected; } selected = selectFallback(organisations, selected); if (!selected) return; if (deepLinkActive) { deepLinkFlowRef.current.targetOrgId = selected.Organisation.id; deepLinkFlowRef.current.stage = "project"; if (selected.Organisation.id !== selectedOrganisationId) { selectOrganisation(selected, { skipUrlUpdate: true }); } return; } if (selected.Organisation.id !== selectedOrganisationId) { selectOrganisation(selected); } }, [organisations, selectedOrganisationId, deepLinkActive, selectOrganisation]); useEffect(() => { if (projects.length === 0) return; if (!deepLinkActive && selectedProjectId == null) { selectProject(projects[0]); return; } if (deepLinkActive) { const flow = deepLinkFlowRef.current; if (flow.stage !== "project") return; if (flow.targetOrgId != null && selectedOrganisationId !== flow.targetOrgId) { return; } let selected = findById(projects, selectedProjectId, (project) => project.Project.id); if (flow.projectKey) { selected = findProjectByKey(flow.projectKey) ?? selected; } selected = selectFallback(projects, selected); if (!selected) return; flow.targetProjectId = selected.Project.id; flow.stage = "issue"; if (selected.Project.id !== selectedProjectId) { selectProject(selected, { skipUrlUpdate: true }); } return; } let selected = findById(projects, selectedProjectId, (project) => project.Project.id); selected = selectFallback(projects, selected); if (selected && selected.Project.id !== selectedProjectId) { selectProject(selected); } }, [projects, selectedProjectId, selectedOrganisationId, deepLinkActive, selectProject]); useEffect(() => { if (!deepLinkActive) return; const flow = deepLinkFlowRef.current; if (flow.stage !== "issue") return; if (flow.targetProjectId != null && selectedProjectId !== flow.targetProjectId) { return; } if (!issuesFetched) return; if (flow.issueNumber != null) { const match = issuesData.find((issue) => issue.Issue.number === flow.issueNumber); if (match && match.Issue.id !== selectedIssueId) { selectIssue(match, { skipUrlUpdate: true }); } } 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", }; return (
{selectedOrganisationId && selectedProjectId && (
{ const nextQuery = event.target.value; handleIssueFiltersChange((current) => ({ ...current, query: nextQuery, })); }} placeholder="Search issues" showCounter={false} />
{selectedOrganisation?.Organisation.features.issueStatus && ( {issueFilters.statuses.length === 0 ? ( "Status" ) : (
{Object.entries(statuses) .filter(([status]) => issueFilters.statuses.includes(status)) .map(([status, colour]) => ( ))}
)}
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 && ( {issueFilters.types.length === 0 ? ( "Type" ) : (
{Object.entries(issueTypes) .filter(([type]) => issueFilters.types.includes(type)) .map(([type, definition]) => (
{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}
))}
)} {issueFilters.assignees.length === 0 ? ( "Assignee" ) : (
{issueFilters.assignees.includes("unassigned") && Unassigned} {members .filter((member) => issueFilters.assignees.includes(String(member.id))) .map((member) => ( ))}
)}
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 && ( {issueFilters.sprintId === "all" ? ( "Sprint" ) : issueFilters.sprintId === "none" ? ( ) : ( (() => { const sprint = sprintsData.find((s) => s.id === issueFilters.sprintId); return sprint ? : "Sprint"; })() )} 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 }); }} > { const params = new URLSearchParams(window.location.search); if (issueFilters.query) { params.set("q", issueFilters.query); } else { params.delete("q"); } if (issueFilters.statuses.length > 0) { params.set("status", issueFilters.statuses.join(",")); } else { params.delete("status"); } if (issueFilters.types.length > 0) { params.set("type", issueFilters.types.join(",")); } else { params.delete("type"); } if (issueFilters.assignees.length > 0) { params.set("assignee", issueFilters.assignees.join(",")); } else { params.delete("assignee"); } if (issueFilters.sprintId !== defaultIssuesTableFilters.sprintId) { params.set("sprint", String(issueFilters.sprintId)); } else { params.delete("sprint"); } if (issueFilters.sort !== defaultIssuesTableFilters.sort) { params.set("sort", issueFilters.sort); } else { params.delete("sort"); } const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; navigator.clipboard.writeText(url); }} >
)} {selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
{selectedIssue && !showIssueModal && ( <>
)}
)} {selectedIssue && showIssueModal && ( { if (!open) { selectIssue(null); } }} /> )}
); }