diff --git a/bun.lock b/bun.lock index 4364b90..29a08bb 100644 --- a/bun.lock +++ b/bun.lock @@ -50,6 +50,8 @@ "@radix-ui/react-tabs": "^1.1.13", "@sprint/shared": "workspace:*", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query-devtools": "^5.91.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "class-variance-authority": "^0.7.1", @@ -424,6 +426,14 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.92.0", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.19", "", { "dependencies": { "@tanstack/query-core": "5.90.19" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="], + "@tauri-apps/api": ["@tauri-apps/api@2.9.1", "", {}, "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw=="], "@tauri-apps/cli": ["@tauri-apps/cli@2.9.6", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.9.6", "@tauri-apps/cli-darwin-x64": "2.9.6", "@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6", "@tauri-apps/cli-linux-arm64-gnu": "2.9.6", "@tauri-apps/cli-linux-arm64-musl": "2.9.6", "@tauri-apps/cli-linux-riscv64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-gnu": "2.9.6", "@tauri-apps/cli-linux-x64-musl": "2.9.6", "@tauri-apps/cli-win32-arm64-msvc": "2.9.6", "@tauri-apps/cli-win32-ia32-msvc": "2.9.6", "@tauri-apps/cli-win32-x64-msvc": "2.9.6" }, "bin": { "tauri": "tauri.js" } }, "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw=="], diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 075c68e..c140708 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -21,6 +21,8 @@ "@radix-ui/react-tabs": "^1.1.13", "@sprint/shared": "workspace:*", "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.19", + "@tanstack/react-query-devtools": "^5.91.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "class-variance-authority": "^0.7.1", diff --git a/packages/frontend/src/components/query-provider.tsx b/packages/frontend/src/components/query-provider.tsx new file mode 100644 index 0000000..dd57e57 --- /dev/null +++ b/packages/frontend/src/components/query-provider.tsx @@ -0,0 +1,13 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import type { ReactNode } from "react"; +import { queryClient } from "@/lib/query/client"; + +export function QueryProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + + ); +} diff --git a/packages/frontend/src/components/selection-provider.tsx b/packages/frontend/src/components/selection-provider.tsx new file mode 100644 index 0000000..fd04a84 --- /dev/null +++ b/packages/frontend/src/components/selection-provider.tsx @@ -0,0 +1,141 @@ +import type { IssueResponse, OrganisationResponse, ProjectResponse } from "@sprint/shared"; +import type { ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState } from "react"; + +type SelectionContextValue = { + selectedOrganisationId: number | null; + selectedProjectId: number | null; + selectedIssueId: number | null; + initialParams: { + orgSlug: string; + projectKey: string; + issueNumber: number | null; + }; + selectOrganisation: (organisation: OrganisationResponse | null) => void; + selectProject: (project: ProjectResponse | null) => void; + selectIssue: (issue: IssueResponse | null) => void; +}; + +const SelectionContext = createContext(null); + +const readStoredId = (key: string) => { + const value = localStorage.getItem(key); + if (!value) return null; + const parsed = Number(value); + return Number.isNaN(parsed) ? null : parsed; +}; + +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); +}; + +export function SelectionProvider({ children }: { children: ReactNode }) { + const initialParams = 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 [selectedOrganisationId, setSelectedOrganisationId] = useState(() => + readStoredId("selectedOrganisationId"), + ); + const [selectedProjectId, setSelectedProjectId] = useState(() => + readStoredId("selectedProjectId"), + ); + const [selectedIssueId, setSelectedIssueId] = useState(null); + + const selectOrganisation = useCallback((organisation: OrganisationResponse | null) => { + const id = organisation?.Organisation.id ?? null; + setSelectedOrganisationId(id); + setSelectedProjectId(null); + setSelectedIssueId(null); + if (id != null) localStorage.setItem("selectedOrganisationId", `${id}`); + else localStorage.removeItem("selectedOrganisationId"); + localStorage.removeItem("selectedProjectId"); + updateUrlParams({ + orgSlug: organisation?.Organisation.slug.toLowerCase() ?? null, + projectKey: null, + issueNumber: null, + }); + }, []); + + const selectProject = useCallback((project: ProjectResponse | null) => { + const id = project?.Project.id ?? null; + setSelectedProjectId(id); + setSelectedIssueId(null); + if (id != null) localStorage.setItem("selectedProjectId", `${id}`); + else localStorage.removeItem("selectedProjectId"); + updateUrlParams({ + projectKey: project?.Project.key.toLowerCase() ?? null, + issueNumber: null, + }); + }, []); + + const selectIssue = useCallback((issue: IssueResponse | null) => { + const id = issue?.Issue.id ?? null; + setSelectedIssueId(id); + updateUrlParams({ issueNumber: issue?.Issue.number ?? null }); + }, []); + + const value = useMemo( + () => ({ + selectedOrganisationId, + selectedProjectId, + selectedIssueId, + initialParams, + selectOrganisation, + selectProject, + selectIssue, + }), + [ + selectedOrganisationId, + selectedProjectId, + selectedIssueId, + initialParams, + selectOrganisation, + selectProject, + selectIssue, + ], + ); + + return {children}; +} + +export function useSelection() { + const context = useContext(SelectionContext); + if (!context) { + throw new Error("useSelection must be used within SelectionProvider"); + } + return context; +} diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 748cc16..4bf0492 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -2,6 +2,8 @@ import "./App.css"; import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { QueryProvider } from "@/components/query-provider"; +import { SelectionProvider } from "@/components/selection-provider"; import { RequireAuth, SessionProvider } from "@/components/session-provider"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; @@ -15,37 +17,41 @@ import Test from "@/pages/Test"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - {/* public routes */} - } /> - } /> - } /> + + + + + + {/* public routes */} + } /> + } /> + } /> - {/* authed routes */} - - - - } - /> - - - - } - /> + {/* authed routes */} + + + + } + /> + + + + } + /> - } /> - - - - + } /> + + + + + + , );