refactored app state to query hooks and selection provider

This commit is contained in:
Oliver Bryan
2026-01-20 17:03:25 +00:00
parent 8f11805bab
commit 83ccc64e84
4 changed files with 163 additions and 501 deletions

View File

@@ -1,22 +1,25 @@
import type { IssueResponse } from "@sprint/shared";
import { useMemo } from "react";
import Avatar from "@/components/avatar";
import { useSelection } from "@/components/selection-provider";
import StatusTag from "@/components/status-tag";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useIssues, useSelectedOrganisation } from "@/lib/query/hooks";
import { cn } from "@/lib/utils";
export function IssuesTable({
issuesData,
columns = {},
issueSelectAction,
statuses,
className,
}: {
issuesData: IssueResponse[];
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
issueSelectAction?: (issue: IssueResponse) => void;
statuses: Record<string, string>;
className: string;
}) {
const { selectedProjectId, selectedIssueId, selectIssue } = useSelection();
const { data: issuesData = [] } = useIssues(selectedProjectId);
const selectedOrganisation = useSelectedOrganisation();
const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const issues = useMemo(() => [...issuesData].reverse(), [issuesData]);
return (
<Table className={cn("table-fixed", className)}>
<TableHeader>
@@ -35,12 +38,16 @@ export function IssuesTable({
</TableRow>
</TableHeader>
<TableBody>
{issuesData.map((issueData) => (
{issues.map((issueData) => (
<TableRow
key={issueData.Issue.id}
className="cursor-pointer max-w-full"
onClick={() => {
issueSelectAction?.(issueData);
if (issueData.Issue.id === selectedIssueId) {
selectIssue(null);
return;
}
selectIssue(issueData);
}}
>
{(columns.id == null || columns.id === true) && (

View File

@@ -1,7 +1,7 @@
import type { OrganisationRecord, OrganisationResponse } from "@sprint/shared";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { OrganisationModal } from "@/components/organisation-modal";
import { useSelection } from "@/components/selection-provider";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -13,22 +13,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useOrganisations } from "@/lib/query/hooks";
export function OrganisationSelect({
organisations,
selectedOrganisation,
onSelectedOrganisationChange,
onCreateOrganisation,
placeholder = "Select Organisation",
contentClass,
showLabel = false,
label = "Organisation",
labelPosition = "top",
}: {
organisations: OrganisationResponse[];
selectedOrganisation: OrganisationResponse | null;
onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void;
onCreateOrganisation?: (org: OrganisationRecord) => void | Promise<void>;
placeholder?: string;
contentClass?: string;
showLabel?: boolean;
@@ -36,6 +29,28 @@ export function OrganisationSelect({
labelPosition?: "top" | "bottom";
}) {
const [open, setOpen] = useState(false);
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
const { data: organisationsData = [] } = useOrganisations();
const { selectedOrganisationId, selectOrganisation } = useSelection();
const organisations = useMemo(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
[organisationsData],
);
const selectedOrganisation = useMemo(
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
[organisations, selectedOrganisationId],
);
useEffect(() => {
if (!pendingOrganisationId) return;
const organisation = organisations.find((org) => org.Organisation.id === pendingOrganisationId);
if (organisation) {
selectOrganisation(organisation);
setPendingOrganisationId(null);
}
}, [organisations, pendingOrganisationId, selectOrganisation]);
return (
<Select
@@ -46,7 +61,7 @@ export function OrganisationSelect({
console.error(`NO ORGANISATION FOUND FOR ID: ${value}`);
return;
}
onSelectedOrganisationChange(organisation);
selectOrganisation(organisation);
}}
onOpenChange={setOpen}
>
@@ -82,7 +97,7 @@ export function OrganisationSelect({
}
completeAction={async (org) => {
try {
await onCreateOrganisation?.(org);
setPendingOrganisationId(org.id);
} catch (err) {
console.error(err);
}

View File

@@ -1,6 +1,6 @@
import type { ProjectRecord, ProjectResponse } from "@sprint/shared";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { ProjectModal } from "@/components/project-modal";
import { useSelection } from "@/components/selection-provider";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -12,29 +12,42 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useProjects } from "@/lib/query/hooks";
export function ProjectSelect({
projects,
selectedProject,
organisationId,
onSelectedProjectChange,
onCreateProject,
placeholder = "Select Project",
showLabel = false,
label = "Project",
labelPosition = "top",
}: {
projects: ProjectResponse[];
selectedProject: ProjectResponse | null;
organisationId: number | undefined;
onSelectedProjectChange: (project: ProjectResponse | null) => void;
onCreateProject?: (project: ProjectRecord) => void | Promise<void>;
placeholder?: string;
showLabel?: boolean;
label?: string;
labelPosition?: "top" | "bottom";
}) {
const [open, setOpen] = useState(false);
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const projects = useMemo(
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
[projectsData],
);
const selectedProject = useMemo(
() => projects.find((proj) => proj.Project.id === selectedProjectId) ?? null,
[projects, selectedProjectId],
);
useEffect(() => {
if (!pendingProjectId) return;
const project = projects.find((proj) => proj.Project.id === pendingProjectId);
if (project) {
selectProject(project);
setPendingProjectId(null);
}
}, [pendingProjectId, projects, selectProject]);
return (
<Select
@@ -45,7 +58,7 @@ export function ProjectSelect({
console.error(`NO PROJECT FOUND FOR ID: ${value}`);
return;
}
onSelectedProjectChange(project);
selectProject(project);
}}
onOpenChange={setOpen}
>
@@ -69,15 +82,20 @@ export function ProjectSelect({
{projects.length > 0 && <SelectSeparator />}
</SelectGroup>
<ProjectModal
organisationId={organisationId}
organisationId={selectedOrganisationId ?? undefined}
trigger={
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!organisationId}>
<Button
size={"sm"}
variant="ghost"
className={"w-full"}
disabled={!selectedOrganisationId}
>
Create Project
</Button>
}
completeAction={async (project) => {
try {
await onCreateProject?.(project);
setPendingProjectId(project.id);
} catch (err) {
console.error(err);
}

View File

@@ -1,15 +1,6 @@
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
import type {
IssueResponse,
OrganisationResponse,
ProjectRecord,
ProjectResponse,
SprintRecord,
UserRecord,
} from "@sprint/shared";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useEffect, useMemo, useRef } from "react";
import AccountDialog from "@/components/account-dialog";
import { IssueDetailPane } from "@/components/issue-detail-pane";
import { IssueModal } from "@/components/issue-modal";
@@ -18,6 +9,7 @@ import LogOutButton from "@/components/log-out-button";
import { OrganisationSelect } from "@/components/organisation-select";
import OrganisationsDialog from "@/components/organisations-dialog";
import { ProjectSelect } from "@/components/project-select";
import { useSelection } from "@/components/selection-provider";
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
import { useAuthenticatedSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display";
@@ -31,40 +23,35 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
import { issue, organisation, project, sprint } from "@/lib/server";
import { issueID } from "@/lib/utils";
import { useIssues, useOrganisations, useProjects, useSelectedIssue } from "@/lib/query/hooks";
const BREATHING_ROOM = 1;
export default function App() {
const { user } = useAuthenticatedSession();
const {
selectedOrganisationId,
selectedProjectId,
selectedIssueId,
initialParams,
selectOrganisation,
selectProject,
selectIssue,
} = useSelection();
const organisationsRef = useRef(false);
const [organisations, setOrganisations] = useState<OrganisationResponse[]>([]);
const [selectedOrganisation, setSelectedOrganisation] = useState<OrganisationResponse | null>(null);
const { data: organisationsData = [] } = useOrganisations();
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const { data: issuesData = [] } = useIssues(selectedProjectId);
const selectedIssue = useSelectedIssue();
const [projects, setProjects] = useState<ProjectResponse[]>([]);
const [selectedProject, setSelectedProject] = useState<ProjectResponse | null>(null);
const [issues, setIssues] = useState<IssueResponse[]>([]);
const [selectedIssue, setSelectedIssue] = useState<IssueResponse | null>(null);
const [members, setMembers] = useState<UserRecord[]>([]);
const [sprints, setSprints] = useState<SprintRecord[]>([]);
const deepLinkParams = useMemo(() => {
const params = new URLSearchParams(window.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,
};
}, []);
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],
);
const deepLinkStateRef = useRef({
appliedOrg: false,
@@ -74,413 +61,88 @@ export default function App() {
projectMatched: false,
});
const initialUrlSyncRef = useRef(false);
useEffect(() => {
if (organisations.length === 0) return;
const updateUrlParams = (updates: {
orgSlug?: string | null;
projectKey?: string | null;
issueNumber?: number | null;
}) => {
const params = new URLSearchParams(window.location.search);
let selected = organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null;
const deepLinkState = deepLinkStateRef.current;
if (updates.orgSlug !== undefined) {
if (updates.orgSlug) params.set("o", updates.orgSlug);
else params.delete("o");
if (!selected && initialParams.orgSlug && !deepLinkState.appliedOrg) {
const match = organisations.find(
(org) => org.Organisation.slug.toLowerCase() === initialParams.orgSlug,
);
deepLinkState.appliedOrg = true;
deepLinkState.orgMatched = Boolean(match);
if (match) {
selected = match;
}
}
if (updates.projectKey !== undefined) {
if (updates.projectKey) params.set("p", updates.projectKey);
else params.delete("p");
if (!selected) {
selected = organisations[0] ?? null;
}
if (updates.issueNumber !== undefined) {
if (updates.issueNumber != null) params.set("i", `${updates.issueNumber}`);
else params.delete("i");
if (selected && selected.Organisation.id !== selectedOrganisationId) {
selectOrganisation(selected);
}
const search = params.toString();
const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}`;
window.history.replaceState(null, "", nextUrl);
};
const refetchOrganisations = async (options?: { selectOrganisationId?: number }) => {
try {
await organisation.byUser({
onSuccess: (data) => {
data.sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name));
setOrganisations(data);
let selected: OrganisationResponse | null = null;
if (options?.selectOrganisationId) {
const created = data.find((o) => o.Organisation.id === options.selectOrganisationId);
if (created) {
selected = created;
}
} else {
const deepLinkState = deepLinkStateRef.current;
if (deepLinkParams.orgSlug && !deepLinkState.appliedOrg) {
const match = data.find(
(org) => org.Organisation.slug.toLowerCase() === deepLinkParams.orgSlug,
);
deepLinkState.appliedOrg = true;
deepLinkState.orgMatched = Boolean(match);
if (match) {
selected = match;
localStorage.setItem("selectedOrganisationId", `${match.Organisation.id}`);
}
}
if (!selected) {
const savedId = localStorage.getItem("selectedOrganisationId");
if (savedId) {
const saved = data.find((o) => o.Organisation.id === Number(savedId));
if (saved) {
selected = saved;
}
}
}
}
if (!selected) {
selected = data[0] || null;
}
setSelectedOrganisation(selected);
if (selected) {
updateUrlParams({
orgSlug: selected.Organisation.slug.toLowerCase(),
projectKey: null,
issueNumber: null,
});
}
},
onError: (error) => {
console.error("error fetching organisations:", error);
setOrganisations([]);
setSelectedOrganisation(null);
toast.error(`Error fetching organisations: ${error}`, {
dismissible: false,
});
},
});
} catch (err) {
console.error("error fetching organisations:", err);
}
};
}, [organisations, selectedOrganisationId, initialParams.orgSlug]);
useEffect(() => {
if (organisationsRef.current) return;
organisationsRef.current = true;
void refetchOrganisations();
}, [user.id]);
if (projects.length === 0) return;
const refetchProjects = async (organisationId: number, options?: { selectProjectId?: number }) => {
try {
await project.byOrganisation({
organisationId,
onSuccess: (data) => {
const projects = data as ProjectResponse[];
projects.sort((a, b) => a.Project.name.localeCompare(b.Project.name));
setProjects(projects);
let selected = projects.find((project) => project.Project.id === selectedProjectId) ?? null;
const deepLinkState = deepLinkStateRef.current;
let selected: ProjectResponse | null = null;
if (options?.selectProjectId) {
const created = projects.find((p) => p.Project.id === options.selectProjectId);
if (created) {
selected = created;
}
} else {
const deepLinkState = deepLinkStateRef.current;
if (
deepLinkParams.projectKey &&
deepLinkState.orgMatched &&
!deepLinkState.appliedProject
) {
const match = projects.find(
(proj) => proj.Project.key.toLowerCase() === deepLinkParams.projectKey,
);
deepLinkState.appliedProject = true;
deepLinkState.projectMatched = Boolean(match);
if (match) {
selected = match;
localStorage.setItem("selectedProjectId", `${match.Project.id}`);
}
}
if (!selected) {
const savedId = localStorage.getItem("selectedProjectId");
if (savedId) {
const saved = projects.find((p) => p.Project.id === Number(savedId));
if (saved) {
selected = saved;
}
}
}
}
if (!selected) {
selected = projects[0] || null;
}
setSelectedProject(selected);
if (selected) {
updateUrlParams({
projectKey: selected.Project.key.toLowerCase(),
issueNumber: null,
});
}
},
onError: (error) => {
console.error("error fetching projects:", error);
setProjects([]);
setSelectedProject(null);
toast.error(`Error fetching projects: ${error}`, {
dismissible: false,
});
},
});
} catch (err) {
console.error("error fetching projects:", err);
setProjects([]);
}
};
const refetchMembers = async (organisationId: number) => {
try {
await organisation.members({
organisationId,
onSuccess: (data) => {
setMembers(data.map((m) => m.User));
},
onError: (error) => {
console.error("error fetching members:", error);
setMembers([]);
toast.error(`Error fetching members: ${error}`, {
dismissible: false,
});
},
});
} catch (err) {
console.error("error fetching members:", err);
setMembers([]);
}
};
const refetchSprints = async (projectId: number) => {
try {
await sprint.byProject({
projectId,
onSuccess: (data) => {
setSprints(data);
},
onError: (error) => {
console.error("error fetching sprints:", error);
setSprints([]);
toast.error(`Error fetching sprints: ${error}`, {
dismissible: false,
});
},
});
} catch (err) {
console.error("error fetching sprints:", err);
setSprints([]);
}
};
// fetch projects when organisation is selected
useEffect(() => {
setProjects([]);
setSelectedProject(null);
setSelectedIssue(null);
setIssues([]);
setMembers([]);
if (!selectedOrganisation) {
return;
if (
!selected &&
initialParams.projectKey &&
deepLinkState.orgMatched &&
!deepLinkState.appliedProject
) {
const match = projects.find(
(project) => project.Project.key.toLowerCase() === initialParams.projectKey,
);
deepLinkState.appliedProject = true;
deepLinkState.projectMatched = Boolean(match);
if (match) {
selected = match;
}
}
void refetchProjects(selectedOrganisation.Organisation.id);
void refetchMembers(selectedOrganisation.Organisation.id);
}, [selectedOrganisation]);
const refetchIssues = async () => {
try {
await issue.byProject({
projectId: selectedProject?.Project.id || 0,
onSuccess: (data) => {
const issues = data as IssueResponse[];
issues.reverse(); // newest at the bottom, but if the order has been rearranged, respect that
setIssues(issues);
const deepLinkState = deepLinkStateRef.current;
if (
deepLinkParams.issueNumber != null &&
deepLinkState.projectMatched &&
!deepLinkState.appliedIssue
) {
const match = issues.find(
(issue) => issue.Issue.number === deepLinkParams.issueNumber,
);
deepLinkState.appliedIssue = true;
setSelectedIssue(match ?? null);
}
},
onError: (error) => {
console.error("error fetching issues:", error);
setIssues([]);
setSelectedIssue(null);
toast.error(`Error fetching issues: ${error}`, {
dismissible: false,
});
},
});
} catch (err) {
console.error("error fetching issues:", err);
setIssues([]);
if (!selected) {
selected = projects[0] ?? null;
}
};
const handleIssueDelete = async (issueId: number) => {
setSelectedIssue(null);
setIssues((prev) => prev.filter((issue) => issue.Issue.id !== issueId));
await refetchIssues();
};
// fetch issues when project is selected
useEffect(() => {
if (!selectedProject) return;
void refetchIssues();
void refetchSprints(selectedProject.Project.id);
}, [selectedProject]);
if (selected && selected.Project.id !== selectedProjectId) {
selectProject(selected);
}
}, [projects, selectedProjectId, initialParams.projectKey]);
useEffect(() => {
if (initialUrlSyncRef.current) return;
if (issuesData.length === 0) return;
if (deepLinkParams.orgSlug || deepLinkParams.projectKey || deepLinkParams.issueNumber != null) {
initialUrlSyncRef.current = true;
return;
const deepLinkState = deepLinkStateRef.current;
if (
initialParams.issueNumber != null &&
deepLinkState.projectMatched &&
!deepLinkState.appliedIssue
) {
const match = issuesData.find((issue) => issue.Issue.number === initialParams.issueNumber);
deepLinkState.appliedIssue = true;
if (match && match.Issue.id !== selectedIssueId) {
selectIssue(match);
}
}
if (new URLSearchParams(window.location.search).toString() !== "") {
initialUrlSyncRef.current = true;
return;
}
if (selectedOrganisation && selectedProject) {
updateUrlParams({
orgSlug: selectedOrganisation.Organisation.slug.toLowerCase(),
projectKey: selectedProject.Project.key.toLowerCase(),
issueNumber: null,
});
initialUrlSyncRef.current = true;
}
}, [deepLinkParams, selectedOrganisation, selectedProject]);
const handleProjectChange = (project: ProjectResponse | null) => {
setSelectedProject(project);
localStorage.setItem("selectedProjectId", `${project?.Project.id}`);
setSelectedIssue(null);
updateUrlParams({
projectKey: project?.Project.key.toLowerCase() ?? null,
issueNumber: null,
});
};
const handleProjectCreate = async (project: ProjectRecord) => {
if (!selectedOrganisation) return;
toast.success(`Created Project ${project.name}`, {
dismissible: false,
});
await refetchProjects(selectedOrganisation.Organisation.id, {
selectProjectId: project.id,
});
};
const handleSprintCreate = async (sprint: SprintRecord) => {
if (!selectedProject) return;
toast.success(
<>
Created sprint <span style={{ color: sprint.color }}>{sprint.name}</span>
</>,
{
dismissible: false,
},
);
await refetchSprints(selectedProject.Project.id);
};
}, [issuesData, selectedIssueId, initialParams.issueNumber]);
return (
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
{/* header area */}
<div className="flex gap-12 items-center justify-between">
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
{/* organisation selection */}
<OrganisationSelect
organisations={organisations}
selectedOrganisation={selectedOrganisation}
onSelectedOrganisationChange={(org) => {
setSelectedOrganisation(org);
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
updateUrlParams({
orgSlug: org?.Organisation.slug.toLowerCase() ?? null,
projectKey: null,
issueNumber: null,
});
}}
onCreateOrganisation={async (org) => {
toast.success(`Created Organisation ${org.name}`, {
dismissible: false,
});
await refetchOrganisations({ selectOrganisationId: org.id });
}}
showLabel
/>
<OrganisationSelect showLabel />
{/* project selection - only shown when organisation is selected */}
{selectedOrganisation && (
<ProjectSelect
projects={projects}
selectedProject={selectedProject}
organisationId={selectedOrganisation?.Organisation.id}
onSelectedProjectChange={handleProjectChange}
onCreateProject={handleProjectCreate}
showLabel
/>
)}
{selectedOrganisation && selectedProject && (
<IssueModal
projectId={selectedProject?.Project.id}
sprints={sprints}
members={members}
statuses={selectedOrganisation.Organisation.statuses}
completeAction={async (issueNumber) => {
if (!selectedProject) return;
toast.success(
`Created ${issueID(selectedProject.Project.key, issueNumber)}`,
{
dismissible: false,
},
);
await refetchIssues();
}}
errorAction={async (errorMessage) => {
toast.error(`Error creating issue: ${errorMessage}`, {
dismissible: false,
});
}}
/>
)}
{selectedOrganisationId && <ProjectSelect showLabel />}
{selectedOrganisationId && selectedProjectId && <IssueModal />}
</div>
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
<ThemeToggle />
@@ -493,18 +155,7 @@ export default function App() {
<AccountDialog />
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<OrganisationsDialog
organisations={organisations}
selectedOrganisation={selectedOrganisation}
setSelectedOrganisation={setSelectedOrganisation}
refetchOrganisations={refetchOrganisations}
projects={projects}
selectedProject={selectedProject}
sprints={sprints}
onSelectedProjectChange={handleProjectChange}
onCreateProject={handleProjectCreate}
onCreateSprint={handleSprintCreate}
/>
<OrganisationsDialog />
</DropdownMenuItem>
<DropdownMenuItem asChild className="flex items-end justify-end">
<ServerConfigurationDialog
@@ -528,47 +179,18 @@ export default function App() {
</div>
</div>
{/* main body */}
{selectedOrganisation && selectedProject && issues.length > 0 && (
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
<ResizablePanelGroup className={`flex-1`}>
<ResizablePanel id={"left"} minSize={400}>
{/* issues list (table) */}
<IssuesTable
issuesData={issues}
columns={{ description: false }}
statuses={selectedOrganisation.Organisation.statuses}
issueSelectAction={(issue) => {
if (issue.Issue.id === selectedIssue?.Issue.id) {
setSelectedIssue(null);
updateUrlParams({ issueNumber: null });
} else {
setSelectedIssue(issue);
updateUrlParams({ issueNumber: issue.Issue.number });
}
}}
className="border w-full flex-shrink"
/>
<IssuesTable columns={{ description: false }} className="border w-full flex-shrink" />
</ResizablePanel>
{/* issue detail pane */}
{selectedIssue && selectedOrganisation && (
{selectedIssue && (
<>
<ResizableSeparator />
<ResizablePanel id={"right"} defaultSize={"30%"} minSize={363} maxSize={"60%"}>
<div className="border">
<IssueDetailPane
project={selectedProject}
sprints={sprints}
issueData={selectedIssue}
members={members}
statuses={selectedOrganisation.Organisation.statuses}
close={() => {
setSelectedIssue(null);
updateUrlParams({ issueNumber: null });
}}
onIssueUpdate={refetchIssues}
onIssueDelete={handleIssueDelete}
/>
<IssueDetailPane />
</div>
</ResizablePanel>
</>