mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +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 Avatar from "@/components/avatar";
|
||||||
|
import { useSelection } from "@/components/selection-provider";
|
||||||
import StatusTag from "@/components/status-tag";
|
import StatusTag from "@/components/status-tag";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useIssues, useSelectedOrganisation } from "@/lib/query/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function IssuesTable({
|
export function IssuesTable({
|
||||||
issuesData,
|
|
||||||
columns = {},
|
columns = {},
|
||||||
issueSelectAction,
|
|
||||||
statuses,
|
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
issuesData: IssueResponse[];
|
|
||||||
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
columns?: { id?: boolean; title?: boolean; description?: boolean; status?: boolean; assignee?: boolean };
|
||||||
issueSelectAction?: (issue: IssueResponse) => void;
|
|
||||||
statuses: Record<string, string>;
|
|
||||||
className: 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 (
|
return (
|
||||||
<Table className={cn("table-fixed", className)}>
|
<Table className={cn("table-fixed", className)}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -35,12 +38,16 @@ export function IssuesTable({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{issuesData.map((issueData) => (
|
{issues.map((issueData) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={issueData.Issue.id}
|
key={issueData.Issue.id}
|
||||||
className="cursor-pointer max-w-full"
|
className="cursor-pointer max-w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
issueSelectAction?.(issueData);
|
if (issueData.Issue.id === selectedIssueId) {
|
||||||
|
selectIssue(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectIssue(issueData);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(columns.id == null || columns.id === true) && (
|
{(columns.id == null || columns.id === true) && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { OrganisationRecord, OrganisationResponse } from "@sprint/shared";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { OrganisationModal } from "@/components/organisation-modal";
|
import { OrganisationModal } from "@/components/organisation-modal";
|
||||||
|
import { useSelection } from "@/components/selection-provider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -13,22 +13,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { useOrganisations } from "@/lib/query/hooks";
|
||||||
|
|
||||||
export function OrganisationSelect({
|
export function OrganisationSelect({
|
||||||
organisations,
|
|
||||||
selectedOrganisation,
|
|
||||||
onSelectedOrganisationChange,
|
|
||||||
onCreateOrganisation,
|
|
||||||
placeholder = "Select Organisation",
|
placeholder = "Select Organisation",
|
||||||
contentClass,
|
contentClass,
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
label = "Organisation",
|
label = "Organisation",
|
||||||
labelPosition = "top",
|
labelPosition = "top",
|
||||||
}: {
|
}: {
|
||||||
organisations: OrganisationResponse[];
|
|
||||||
selectedOrganisation: OrganisationResponse | null;
|
|
||||||
onSelectedOrganisationChange: (organisation: OrganisationResponse | null) => void;
|
|
||||||
onCreateOrganisation?: (org: OrganisationRecord) => void | Promise<void>;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
contentClass?: string;
|
contentClass?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
@@ -36,6 +29,28 @@ export function OrganisationSelect({
|
|||||||
labelPosition?: "top" | "bottom";
|
labelPosition?: "top" | "bottom";
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@@ -46,7 +61,7 @@ export function OrganisationSelect({
|
|||||||
console.error(`NO ORGANISATION FOUND FOR ID: ${value}`);
|
console.error(`NO ORGANISATION FOUND FOR ID: ${value}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelectedOrganisationChange(organisation);
|
selectOrganisation(organisation);
|
||||||
}}
|
}}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
@@ -82,7 +97,7 @@ export function OrganisationSelect({
|
|||||||
}
|
}
|
||||||
completeAction={async (org) => {
|
completeAction={async (org) => {
|
||||||
try {
|
try {
|
||||||
await onCreateOrganisation?.(org);
|
setPendingOrganisationId(org.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ProjectRecord, ProjectResponse } from "@sprint/shared";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useState } from "react";
|
|
||||||
import { ProjectModal } from "@/components/project-modal";
|
import { ProjectModal } from "@/components/project-modal";
|
||||||
|
import { useSelection } from "@/components/selection-provider";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -12,29 +12,42 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { useProjects } from "@/lib/query/hooks";
|
||||||
|
|
||||||
export function ProjectSelect({
|
export function ProjectSelect({
|
||||||
projects,
|
|
||||||
selectedProject,
|
|
||||||
organisationId,
|
|
||||||
onSelectedProjectChange,
|
|
||||||
onCreateProject,
|
|
||||||
placeholder = "Select Project",
|
placeholder = "Select Project",
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
label = "Project",
|
label = "Project",
|
||||||
labelPosition = "top",
|
labelPosition = "top",
|
||||||
}: {
|
}: {
|
||||||
projects: ProjectResponse[];
|
|
||||||
selectedProject: ProjectResponse | null;
|
|
||||||
organisationId: number | undefined;
|
|
||||||
onSelectedProjectChange: (project: ProjectResponse | null) => void;
|
|
||||||
onCreateProject?: (project: ProjectRecord) => void | Promise<void>;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
labelPosition?: "top" | "bottom";
|
labelPosition?: "top" | "bottom";
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
@@ -45,7 +58,7 @@ export function ProjectSelect({
|
|||||||
console.error(`NO PROJECT FOUND FOR ID: ${value}`);
|
console.error(`NO PROJECT FOUND FOR ID: ${value}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelectedProjectChange(project);
|
selectProject(project);
|
||||||
}}
|
}}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
>
|
>
|
||||||
@@ -69,15 +82,20 @@ export function ProjectSelect({
|
|||||||
{projects.length > 0 && <SelectSeparator />}
|
{projects.length > 0 && <SelectSeparator />}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
<ProjectModal
|
<ProjectModal
|
||||||
organisationId={organisationId}
|
organisationId={selectedOrganisationId ?? undefined}
|
||||||
trigger={
|
trigger={
|
||||||
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!organisationId}>
|
<Button
|
||||||
|
size={"sm"}
|
||||||
|
variant="ghost"
|
||||||
|
className={"w-full"}
|
||||||
|
disabled={!selectedOrganisationId}
|
||||||
|
>
|
||||||
Create Project
|
Create Project
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
completeAction={async (project) => {
|
completeAction={async (project) => {
|
||||||
try {
|
try {
|
||||||
await onCreateProject?.(project);
|
setPendingProjectId(project.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
||||||
|
|
||||||
import type {
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
IssueResponse,
|
|
||||||
OrganisationResponse,
|
|
||||||
ProjectRecord,
|
|
||||||
ProjectResponse,
|
|
||||||
SprintRecord,
|
|
||||||
UserRecord,
|
|
||||||
} from "@sprint/shared";
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import AccountDialog from "@/components/account-dialog";
|
import AccountDialog from "@/components/account-dialog";
|
||||||
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
||||||
import { IssueModal } from "@/components/issue-modal";
|
import { IssueModal } from "@/components/issue-modal";
|
||||||
@@ -18,6 +9,7 @@ import LogOutButton from "@/components/log-out-button";
|
|||||||
import { OrganisationSelect } from "@/components/organisation-select";
|
import { OrganisationSelect } from "@/components/organisation-select";
|
||||||
import OrganisationsDialog from "@/components/organisations-dialog";
|
import OrganisationsDialog from "@/components/organisations-dialog";
|
||||||
import { ProjectSelect } from "@/components/project-select";
|
import { ProjectSelect } from "@/components/project-select";
|
||||||
|
import { useSelection } from "@/components/selection-provider";
|
||||||
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
|
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
|
||||||
import { useAuthenticatedSession } from "@/components/session-provider";
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
||||||
import SmallUserDisplay from "@/components/small-user-display";
|
import SmallUserDisplay from "@/components/small-user-display";
|
||||||
@@ -31,40 +23,35 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
||||||
import { issue, organisation, project, sprint } from "@/lib/server";
|
import { useIssues, useOrganisations, useProjects, useSelectedIssue } from "@/lib/query/hooks";
|
||||||
import { issueID } from "@/lib/utils";
|
|
||||||
|
|
||||||
const BREATHING_ROOM = 1;
|
const BREATHING_ROOM = 1;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { user } = useAuthenticatedSession();
|
const { user } = useAuthenticatedSession();
|
||||||
|
const {
|
||||||
|
selectedOrganisationId,
|
||||||
|
selectedProjectId,
|
||||||
|
selectedIssueId,
|
||||||
|
initialParams,
|
||||||
|
selectOrganisation,
|
||||||
|
selectProject,
|
||||||
|
selectIssue,
|
||||||
|
} = useSelection();
|
||||||
|
|
||||||
const organisationsRef = useRef(false);
|
const { data: organisationsData = [] } = useOrganisations();
|
||||||
const [organisations, setOrganisations] = useState<OrganisationResponse[]>([]);
|
const { data: projectsData = [] } = useProjects(selectedOrganisationId);
|
||||||
const [selectedOrganisation, setSelectedOrganisation] = useState<OrganisationResponse | null>(null);
|
const { data: issuesData = [] } = useIssues(selectedProjectId);
|
||||||
|
const selectedIssue = useSelectedIssue();
|
||||||
|
|
||||||
const [projects, setProjects] = useState<ProjectResponse[]>([]);
|
const organisations = useMemo(
|
||||||
const [selectedProject, setSelectedProject] = useState<ProjectResponse | null>(null);
|
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
|
||||||
|
[organisationsData],
|
||||||
const [issues, setIssues] = useState<IssueResponse[]>([]);
|
);
|
||||||
const [selectedIssue, setSelectedIssue] = useState<IssueResponse | null>(null);
|
const projects = useMemo(
|
||||||
|
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
|
||||||
const [members, setMembers] = useState<UserRecord[]>([]);
|
[projectsData],
|
||||||
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 deepLinkStateRef = useRef({
|
const deepLinkStateRef = useRef({
|
||||||
appliedOrg: false,
|
appliedOrg: false,
|
||||||
@@ -74,413 +61,88 @@ export default function App() {
|
|||||||
projectMatched: false,
|
projectMatched: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const initialUrlSyncRef = useRef(false);
|
useEffect(() => {
|
||||||
|
if (organisations.length === 0) return;
|
||||||
|
|
||||||
const updateUrlParams = (updates: {
|
let selected = organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null;
|
||||||
orgSlug?: string | null;
|
const deepLinkState = deepLinkStateRef.current;
|
||||||
projectKey?: string | null;
|
|
||||||
issueNumber?: number | null;
|
|
||||||
}) => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
if (updates.orgSlug !== undefined) {
|
if (!selected && initialParams.orgSlug && !deepLinkState.appliedOrg) {
|
||||||
if (updates.orgSlug) params.set("o", updates.orgSlug);
|
const match = organisations.find(
|
||||||
else params.delete("o");
|
(org) => org.Organisation.slug.toLowerCase() === initialParams.orgSlug,
|
||||||
|
);
|
||||||
|
deepLinkState.appliedOrg = true;
|
||||||
|
deepLinkState.orgMatched = Boolean(match);
|
||||||
|
if (match) {
|
||||||
|
selected = match;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.projectKey !== undefined) {
|
if (!selected) {
|
||||||
if (updates.projectKey) params.set("p", updates.projectKey);
|
selected = organisations[0] ?? null;
|
||||||
else params.delete("p");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.issueNumber !== undefined) {
|
if (selected && selected.Organisation.id !== selectedOrganisationId) {
|
||||||
if (updates.issueNumber != null) params.set("i", `${updates.issueNumber}`);
|
selectOrganisation(selected);
|
||||||
else params.delete("i");
|
|
||||||
}
|
}
|
||||||
|
}, [organisations, selectedOrganisationId, initialParams.orgSlug]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organisationsRef.current) return;
|
if (projects.length === 0) return;
|
||||||
organisationsRef.current = true;
|
|
||||||
void refetchOrganisations();
|
|
||||||
}, [user.id]);
|
|
||||||
|
|
||||||
const refetchProjects = async (organisationId: number, options?: { selectProjectId?: number }) => {
|
let selected = projects.find((project) => project.Project.id === selectedProjectId) ?? null;
|
||||||
try {
|
const deepLinkState = deepLinkStateRef.current;
|
||||||
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 (
|
||||||
|
!selected &&
|
||||||
if (options?.selectProjectId) {
|
initialParams.projectKey &&
|
||||||
const created = projects.find((p) => p.Project.id === options.selectProjectId);
|
deepLinkState.orgMatched &&
|
||||||
if (created) {
|
!deepLinkState.appliedProject
|
||||||
selected = created;
|
) {
|
||||||
}
|
const match = projects.find(
|
||||||
} else {
|
(project) => project.Project.key.toLowerCase() === initialParams.projectKey,
|
||||||
const deepLinkState = deepLinkStateRef.current;
|
);
|
||||||
if (
|
deepLinkState.appliedProject = true;
|
||||||
deepLinkParams.projectKey &&
|
deepLinkState.projectMatched = Boolean(match);
|
||||||
deepLinkState.orgMatched &&
|
if (match) {
|
||||||
!deepLinkState.appliedProject
|
selected = match;
|
||||||
) {
|
}
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void refetchProjects(selectedOrganisation.Organisation.id);
|
if (!selected) {
|
||||||
void refetchMembers(selectedOrganisation.Organisation.id);
|
selected = projects[0] ?? null;
|
||||||
}, [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([]);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleIssueDelete = async (issueId: number) => {
|
if (selected && selected.Project.id !== selectedProjectId) {
|
||||||
setSelectedIssue(null);
|
selectProject(selected);
|
||||||
setIssues((prev) => prev.filter((issue) => issue.Issue.id !== issueId));
|
}
|
||||||
await refetchIssues();
|
}, [projects, selectedProjectId, initialParams.projectKey]);
|
||||||
};
|
|
||||||
|
|
||||||
// fetch issues when project is selected
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedProject) return;
|
|
||||||
|
|
||||||
void refetchIssues();
|
|
||||||
void refetchSprints(selectedProject.Project.id);
|
|
||||||
}, [selectedProject]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialUrlSyncRef.current) return;
|
if (issuesData.length === 0) return;
|
||||||
|
|
||||||
if (deepLinkParams.orgSlug || deepLinkParams.projectKey || deepLinkParams.issueNumber != null) {
|
const deepLinkState = deepLinkStateRef.current;
|
||||||
initialUrlSyncRef.current = true;
|
if (
|
||||||
return;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}, [issuesData, selectedIssueId, initialParams.issueNumber]);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
<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-12 items-center justify-between">
|
||||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||||
{/* organisation selection */}
|
<OrganisationSelect showLabel />
|
||||||
<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
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* project selection - only shown when organisation is selected */}
|
{selectedOrganisationId && <ProjectSelect showLabel />}
|
||||||
{selectedOrganisation && (
|
{selectedOrganisationId && selectedProjectId && <IssueModal />}
|
||||||
<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,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
@@ -493,18 +155,7 @@ export default function App() {
|
|||||||
<AccountDialog />
|
<AccountDialog />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||||
<OrganisationsDialog
|
<OrganisationsDialog />
|
||||||
organisations={organisations}
|
|
||||||
selectedOrganisation={selectedOrganisation}
|
|
||||||
setSelectedOrganisation={setSelectedOrganisation}
|
|
||||||
refetchOrganisations={refetchOrganisations}
|
|
||||||
projects={projects}
|
|
||||||
selectedProject={selectedProject}
|
|
||||||
sprints={sprints}
|
|
||||||
onSelectedProjectChange={handleProjectChange}
|
|
||||||
onCreateProject={handleProjectCreate}
|
|
||||||
onCreateSprint={handleSprintCreate}
|
|
||||||
/>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild className="flex items-end justify-end">
|
<DropdownMenuItem asChild className="flex items-end justify-end">
|
||||||
<ServerConfigurationDialog
|
<ServerConfigurationDialog
|
||||||
@@ -528,47 +179,18 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* main body */}
|
{selectedOrganisationId && selectedProjectId && issuesData.length > 0 && (
|
||||||
{selectedOrganisation && selectedProject && issues.length > 0 && (
|
|
||||||
<ResizablePanelGroup className={`flex-1`}>
|
<ResizablePanelGroup className={`flex-1`}>
|
||||||
<ResizablePanel id={"left"} minSize={400}>
|
<ResizablePanel id={"left"} minSize={400}>
|
||||||
{/* issues list (table) */}
|
<IssuesTable columns={{ description: false }} className="border w-full flex-shrink" />
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
{/* issue detail pane */}
|
{selectedIssue && (
|
||||||
{selectedIssue && selectedOrganisation && (
|
|
||||||
<>
|
<>
|
||||||
<ResizableSeparator />
|
<ResizableSeparator />
|
||||||
<ResizablePanel id={"right"} defaultSize={"30%"} minSize={363} maxSize={"60%"}>
|
<ResizablePanel id={"right"} defaultSize={"30%"} minSize={363} maxSize={"60%"}>
|
||||||
<div className="border">
|
<div className="border">
|
||||||
<IssueDetailPane
|
<IssueDetailPane />
|
||||||
project={selectedProject}
|
|
||||||
sprints={sprints}
|
|
||||||
issueData={selectedIssue}
|
|
||||||
members={members}
|
|
||||||
statuses={selectedOrganisation.Organisation.statuses}
|
|
||||||
close={() => {
|
|
||||||
setSelectedIssue(null);
|
|
||||||
updateUrlParams({ issueNumber: null });
|
|
||||||
}}
|
|
||||||
onIssueUpdate={refetchIssues}
|
|
||||||
onIssueDelete={handleIssueDelete}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user