mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
refactored app state to query hooks and selection provider
This commit is contained in:
@@ -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) && (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
if (updates.orgSlug !== undefined) {
|
||||
if (updates.orgSlug) params.set("o", updates.orgSlug);
|
||||
else params.delete("o");
|
||||
}
|
||||
|
||||
if (updates.projectKey !== undefined) {
|
||||
if (updates.projectKey) params.set("p", updates.projectKey);
|
||||
else params.delete("p");
|
||||
}
|
||||
|
||||
if (updates.issueNumber !== undefined) {
|
||||
if (updates.issueNumber != null) params.set("i", `${updates.issueNumber}`);
|
||||
else params.delete("i");
|
||||
}
|
||||
|
||||
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 {
|
||||
let selected = organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null;
|
||||
const deepLinkState = deepLinkStateRef.current;
|
||||
if (deepLinkParams.orgSlug && !deepLinkState.appliedOrg) {
|
||||
const match = data.find(
|
||||
(org) => org.Organisation.slug.toLowerCase() === deepLinkParams.orgSlug,
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
selected = organisations[0] ?? null;
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = data[0] || null;
|
||||
if (selected && selected.Organisation.id !== selectedOrganisationId) {
|
||||
selectOrganisation(selected);
|
||||
}
|
||||
|
||||
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: ProjectResponse | null = null;
|
||||
|
||||
if (options?.selectProjectId) {
|
||||
const created = projects.find((p) => p.Project.id === options.selectProjectId);
|
||||
if (created) {
|
||||
selected = created;
|
||||
}
|
||||
} else {
|
||||
let selected = projects.find((project) => project.Project.id === selectedProjectId) ?? null;
|
||||
const deepLinkState = deepLinkStateRef.current;
|
||||
|
||||
if (
|
||||
deepLinkParams.projectKey &&
|
||||
!selected &&
|
||||
initialParams.projectKey &&
|
||||
deepLinkState.orgMatched &&
|
||||
!deepLinkState.appliedProject
|
||||
) {
|
||||
const match = projects.find(
|
||||
(proj) => proj.Project.key.toLowerCase() === deepLinkParams.projectKey,
|
||||
(project) => project.Project.key.toLowerCase() === initialParams.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
selected = projects[0] ?? null;
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
selected = projects[0] || null;
|
||||
if (selected && selected.Project.id !== selectedProjectId) {
|
||||
selectProject(selected);
|
||||
}
|
||||
}, [projects, selectedProjectId, initialParams.projectKey]);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
if (issuesData.length === 0) return;
|
||||
|
||||
const deepLinkState = deepLinkStateRef.current;
|
||||
if (
|
||||
deepLinkParams.issueNumber != null &&
|
||||
initialParams.issueNumber != null &&
|
||||
deepLinkState.projectMatched &&
|
||||
!deepLinkState.appliedIssue
|
||||
) {
|
||||
const match = issues.find(
|
||||
(issue) => issue.Issue.number === deepLinkParams.issueNumber,
|
||||
);
|
||||
const match = issuesData.find((issue) => issue.Issue.number === initialParams.issueNumber);
|
||||
deepLinkState.appliedIssue = true;
|
||||
setSelectedIssue(match ?? null);
|
||||
if (match && match.Issue.id !== selectedIssueId) {
|
||||
selectIssue(match);
|
||||
}
|
||||
},
|
||||
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([]);
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialUrlSyncRef.current) return;
|
||||
|
||||
if (deepLinkParams.orgSlug || deepLinkParams.projectKey || deepLinkParams.issueNumber != null) {
|
||||
initialUrlSyncRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user