diff --git a/packages/frontend/src/components/issues-table.tsx b/packages/frontend/src/components/issues-table.tsx index 9220ded..aeee09d 100644 --- a/packages/frontend/src/components/issues-table.tsx +++ b/packages/frontend/src/components/issues-table.tsx @@ -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; 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 ( @@ -35,12 +38,16 @@ export function IssuesTable({ - {issuesData.map((issueData) => ( + {issues.map((issueData) => ( { - issueSelectAction?.(issueData); + if (issueData.Issue.id === selectedIssueId) { + selectIssue(null); + return; + } + selectIssue(issueData); }} > {(columns.id == null || columns.id === true) && ( diff --git a/packages/frontend/src/components/organisation-select.tsx b/packages/frontend/src/components/organisation-select.tsx index cf64139..9b0ba41 100644 --- a/packages/frontend/src/components/organisation-select.tsx +++ b/packages/frontend/src/components/organisation-select.tsx @@ -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; 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(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 ( @@ -69,15 +82,20 @@ export function ProjectSelect({ {projects.length > 0 && } + } completeAction={async (project) => { try { - await onCreateProject?.(project); + setPendingProjectId(project.id); } catch (err) { console.error(err); } diff --git a/packages/frontend/src/pages/App.tsx b/packages/frontend/src/pages/App.tsx index abf68ae..5316e36 100644 --- a/packages/frontend/src/pages/App.tsx +++ b/packages/frontend/src/pages/App.tsx @@ -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([]); - const [selectedOrganisation, setSelectedOrganisation] = useState(null); + const { data: organisationsData = [] } = useOrganisations(); + const { data: projectsData = [] } = useProjects(selectedOrganisationId); + const { data: issuesData = [] } = useIssues(selectedProjectId); + const selectedIssue = useSelectedIssue(); - const [projects, setProjects] = useState([]); - const [selectedProject, setSelectedProject] = useState(null); - - const [issues, setIssues] = useState([]); - const [selectedIssue, setSelectedIssue] = useState(null); - - const [members, setMembers] = useState([]); - const [sprints, setSprints] = useState([]); - - 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 {sprint.name} - , - { - dismissible: false, - }, - ); - - await refetchSprints(selectedProject.Project.id); - }; + }, [issuesData, selectedIssueId, initialParams.issueNumber]); return (
- {/* header area */}
- {/* organisation selection */} - { - 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 */} - {selectedOrganisation && ( - - )} - {selectedOrganisation && selectedProject && ( - { - 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 && } + {selectedOrganisationId && selectedProjectId && }
@@ -493,18 +155,7 @@ export default function App() { - +
- {/* main body */} - {selectedOrganisation && selectedProject && issues.length > 0 && ( + {selectedOrganisationId && selectedProjectId && issuesData.length > 0 && ( - {/* issues list (table) */} - { - 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" - /> + - {/* issue detail pane */} - {selectedIssue && selectedOrganisation && ( + {selectedIssue && ( <>
- { - setSelectedIssue(null); - updateUrlParams({ issueNumber: null }); - }} - onIssueUpdate={refetchIssues} - onIssueDelete={handleIssueDelete} - /> +