diff --git a/.github/workflows/tests-js.yaml b/.github/workflows/tests-js.yaml index 1255ac068..b25599ee9 100644 --- a/.github/workflows/tests-js.yaml +++ b/.github/workflows/tests-js.yaml @@ -49,6 +49,10 @@ jobs: working-directory: ./js run: pnpm lint + - name: Run Jest Tests + working-directory: ./js + run: pnpm test + - name: Build the static files working-directory: ./js run: pnpm build diff --git a/js/.husky/pre-commit b/js/.husky/pre-commit index 0a633112d..51905c8a6 100755 --- a/js/.husky/pre-commit +++ b/js/.husky/pre-commit @@ -8,6 +8,7 @@ if [ -n "$staged_js_files" ]; then pushd js pnpm lint:staged pnpm type:check + pnpm test popd else echo "No TypeScript/JavaScript files changed, skipping lint and type check." diff --git a/js/app/(mainComponents)/NavBar.tsx b/js/app/(mainComponents)/NavBar.tsx new file mode 100644 index 000000000..bc43c38f6 --- /dev/null +++ b/js/app/(mainComponents)/NavBar.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { Box, Flex, Link, Tabs } from "@chakra-ui/react"; +import { useQuery } from "@tanstack/react-query"; +import NextLink from "next/link"; +import { usePathname } from "next/navigation"; +import React, { Activity, ReactNode, useEffect, useMemo } from "react"; +import { EnvInfo } from "@/components/app/EnvInfo"; +import { Filename } from "@/components/app/Filename"; +import { StateExporter } from "@/components/app/StateExporter"; +import { TopLevelShare } from "@/components/app/StateSharing"; +import { StateSynchronizer } from "@/components/app/StateSynchronizer"; +import { cacheKeys } from "@/lib/api/cacheKeys"; +import { Check, listChecks } from "@/lib/api/checks"; +import { trackNavigation } from "@/lib/api/track"; +import { useLineageGraphContext } from "@/lib/hooks/LineageGraphContext"; +import { useRecceInstanceContext } from "@/lib/hooks/RecceInstanceContext"; +import { useRecceServerFlag } from "@/lib/hooks/useRecceServerFlag"; + +/** + * Route configuration for tabs + */ +const ROUTE_CONFIG = [ + { path: "/lineage", name: "Lineage" }, + { path: "/query", name: "Query" }, + { path: "/checks", name: "Checklist" }, +] as const; + +interface TabBadgeProps { + queryKey: string[]; + fetchCallback: () => Promise; + selectCallback?: (data: T) => number; +} + +function TabBadge({ + queryKey, + fetchCallback, + selectCallback, +}: TabBadgeProps): ReactNode { + const { + data: count, + isLoading, + error, + } = useQuery({ + queryKey: queryKey, + queryFn: fetchCallback, + select: selectCallback, + }); + + if (isLoading || error || count === 0) { + return <>; + } + + return ( + + {count} + + ); +} + +// NavBar component with Next.js Link navigation +export default function NavBar() { + const pathname = usePathname(); + const { isDemoSite, isLoading, cloudMode } = useLineageGraphContext(); + const { featureToggles } = useRecceInstanceContext(); + const { data: flag, isLoading: isFlagLoading } = useRecceServerFlag(); + const ChecklistBadge = ( + + queryKey={cacheKeys.checks()} + fetchCallback={listChecks} + selectCallback={(checks: Check[]) => { + return checks.filter((check) => !check.is_checked).length; + }} + /> + ); + // Track navigation changes + useEffect(() => { + trackNavigation({ from: location.pathname, to: pathname }); + }, [pathname]); + + // Get current tab value from pathname + const currentTab = useMemo(() => { + if (pathname.startsWith("/checks")) return "/checks"; + if (pathname.startsWith("/query")) return "/query"; + if (pathname.startsWith("/runs")) return "/runs"; + return "/lineage"; + }, [pathname]); + + return ( + + + {/* Left section: Tabs */} + + {ROUTE_CONFIG.map(({ path, name }) => { + const disable = name === "Query" && flag?.single_env_onboarding; + + return ( + + ); + })} + + + {/* Center section: Filename and TopLevelShare */} + + {!isLoading && !isDemoSite && } + {!isLoading && + !isDemoSite && + !flag?.single_env_onboarding && + !featureToggles.disableShare && } + + + {/* Right section: EnvInfo, StateSynchronizer, StateExporter */} + {!isLoading && ( + + + {cloudMode && } + + + )} + + + ); +} diff --git a/js/app/(mainComponents)/RecceVersionBadge.tsx b/js/app/(mainComponents)/RecceVersionBadge.tsx new file mode 100644 index 000000000..4641b33fd --- /dev/null +++ b/js/app/(mainComponents)/RecceVersionBadge.tsx @@ -0,0 +1,90 @@ +import { Badge, Code, Link, Text } from "@chakra-ui/react"; +import React, { useEffect, useMemo } from "react"; +import { toaster } from "@/components/ui/toaster"; +import { useVersionNumber } from "@/lib/api/version"; + +export default function RecceVersionBadge() { + const { version, latestVersion } = useVersionNumber(); + const versionFormatRegex = useMemo( + () => new RegExp("^\\d+\\.\\d+\\.\\d+$"), + [], + ); + + useEffect(() => { + if (versionFormatRegex.test(version) && version !== latestVersion) { + const storageKey = "recce-update-toast-shown"; + const hasShownForThisVersion = sessionStorage.getItem(storageKey); + if (hasShownForThisVersion) { + return; + } + // Defer toast creation to next tick to avoid React's flushSync error + // This prevents "flushSync called from inside lifecycle method" when + // the toast library tries to immediately update DOM during render cycle + setTimeout(() => { + toaster.create({ + id: "recce-update-available", // Fixed ID prevents duplicates + title: "Update available", + description: ( + + A new version of Recce (v{latestVersion}) is available. +
+ Please run pip install --upgrade recce to update + Recce. +
+ + Click here to view the detail of latest release + +
+ ), + duration: 60 * 1000, + // TODO Fix this at a later update + // containerStyle: { + // background: "rgba(20, 20, 20, 0.6)", // Semi-transparent black + // color: "white", // Ensure text is visible + // backdropFilter: "blur(10px)", // Frosted glass effect + // borderRadius: "8px", + // }, + closable: true, + }); + sessionStorage.setItem(storageKey, "true"); + }, 0); + } + }, [version, latestVersion, versionFormatRegex]); + + if (!versionFormatRegex.test(version)) { + // If the version is not in the format of x.y.z, don't apply + return ( + + {version} + + ); + } + + // Link to the release page on GitHub if the version is in the format of x.y.z + return ( + + + {version} + + + ); +} diff --git a/js/app/(mainComponents)/TopBar.tsx b/js/app/(mainComponents)/TopBar.tsx new file mode 100644 index 000000000..382dff5bc --- /dev/null +++ b/js/app/(mainComponents)/TopBar.tsx @@ -0,0 +1,212 @@ +import { + Badge, + Box, + Flex, + Heading, + HStack, + Icon, + Image, + Link, + LinkProps, + Spacer, + Text, +} from "@chakra-ui/react"; +import RecceVersionBadge from "app/(mainComponents)/RecceVersionBadge"; +import React, { useState } from "react"; +import { IconType } from "react-icons"; +import { FaGithub, FaQuestionCircle, FaSlack } from "react-icons/fa"; +import { VscGitPullRequest } from "react-icons/vsc"; +import AuthModal from "@/components/AuthModal/AuthModal"; +import AvatarDropdown from "@/components/app/AvatarDropdown"; +import { IdleTimeoutBadge } from "@/components/timeout/IdleTimeoutBadge"; +import { useLineageGraphContext } from "@/lib/hooks/LineageGraphContext"; +import { useRecceInstanceContext } from "@/lib/hooks/RecceInstanceContext"; + +interface LinkIconProps extends LinkProps { + icon: IconType; + href: string; +} + +function LinkIcon({ icon, href, ...prob }: LinkIconProps) { + return ( + + + + ); +} + +export default function TopBar() { + const { reviewMode, isDemoSite, envInfo, cloudMode } = + useLineageGraphContext(); + const { featureToggles, authed } = useRecceInstanceContext(); + const { url: prURL, id: prID } = envInfo?.pullRequest ?? {}; + const demoPrId = prURL ? prURL.split("/").pop() : null; + const brandLink = + cloudMode || authed + ? "https://cloud.datarecce.io/" + : "https://reccehq.com/"; + const [showModal, setShowModal] = useState(false); + + return ( + + + + recce-logo-white + + RECCE + + + + + {(featureToggles.mode ?? reviewMode) && ( + + {featureToggles.mode ?? "review mode"} + + )} + {cloudMode && prID && ( + + + cloud mode + + + + {`#${String(prID)}`} + + + + + )} + {isDemoSite && prURL && demoPrId && ( + + + demo mode + + + + {`#${demoPrId}`} + + + + + )} + + + {(isDemoSite || featureToggles.mode === "read only") && ( + <> + + + + + )} + {!isDemoSite && featureToggles.mode !== "read only" && ( + <> + + {authed || cloudMode ? ( + + + + ) : ( + <> + { + setShowModal(true); + }} + > + Connect to Cloud + + {showModal && ( + + )} + + )} + + )} + + ); +} diff --git a/js/app/@lineage/default.tsx b/js/app/@lineage/default.tsx new file mode 100644 index 000000000..3510be35b --- /dev/null +++ b/js/app/@lineage/default.tsx @@ -0,0 +1,20 @@ +/** + * @lineage Parallel Route - Default + * + * This file is rendered when navigating to routes that don't have + * a matching @lineage segment. It still renders LineagePage to + * keep it mounted (though hidden via CSS in MainLayout). + * + * This is crucial for preserving React state across navigation. + * + * @see https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes#defaultjs + */ +"use client"; + +import { LineagePage } from "@/components/lineage/LineagePage"; + +export default function LineageSlotDefault() { + // Still render LineagePage to keep it mounted + // MainLayout controls visibility via CSS display property + return ; +} diff --git a/js/app/@lineage/page.tsx b/js/app/@lineage/page.tsx new file mode 100644 index 000000000..4addb1213 --- /dev/null +++ b/js/app/@lineage/page.tsx @@ -0,0 +1,14 @@ +/** + * @lineage Parallel Route - Page + * + * This parallel route renders the LineagePage component. + * It is always mounted in the layout to preserve React state + * (React Flow graph state, zoom level, selected nodes, etc.) + */ +"use client"; + +import { LineagePage } from "@/components/lineage/LineagePage"; + +export default function LineageSlotPage() { + return ; +} diff --git a/js/app/MainLayout.tsx b/js/app/MainLayout.tsx new file mode 100644 index 000000000..86e703f99 --- /dev/null +++ b/js/app/MainLayout.tsx @@ -0,0 +1,134 @@ +/** + * MainLayout - Handles parallel route visibility and main app structure + * + * This component manages the visibility of the @lineage parallel route + * while keeping it mounted to preserve React state (React Flow graph state, etc.) + */ + +"use client"; + +import { Box, Center, Flex, Spinner } from "@chakra-ui/react"; +import { usePathname } from "next/navigation"; +import React, { ReactNode, Suspense, useEffect } from "react"; +import AuthModal from "@/components/AuthModal/AuthModal"; +import { RunList } from "@/components/run/RunList"; +import { RunResultPane } from "@/components/run/RunResultPane"; +import { HSplit, VSplit } from "@/components/split/Split"; +import { trackInit } from "@/lib/api/track"; +import { useLineageGraphContext } from "@/lib/hooks/LineageGraphContext"; +import { useRecceActionContext } from "@/lib/hooks/RecceActionContext"; +import { useRecceInstanceContext } from "@/lib/hooks/RecceInstanceContext"; +import { useRecceServerFlag } from "@/lib/hooks/useRecceServerFlag"; +import "@fontsource/montserrat/800.css"; +import NavBar from "app/(mainComponents)/NavBar"; +import TopBar from "app/(mainComponents)/TopBar"; + +interface MainLayoutProps { + children: ReactNode; + /** Parallel route slot from @lineage */ + lineage: ReactNode; +} + +function MainContentLoading(): ReactNode { + return ( + +
+ +
+
+ ); +} + +export function MainLayout({ children, lineage }: MainLayoutProps) { + const pathname = usePathname(); + const { isDemoSite, isLoading, isCodespace } = useLineageGraphContext(); + const { featureToggles } = useRecceInstanceContext(); + + // Determine if lineage route is active + const isLineageRoute = pathname === "/lineage" || pathname === "/"; + + useEffect(() => { + trackInit(); + }, []); + + return ( + + + +
+ {children} +
+ {!isLoading && + !isDemoSite && + !isCodespace && + featureToggles.mode === null && } +
+ ); +} + +// Main content area with parallel route handling +interface MainProps { + children: ReactNode; + lineage: ReactNode; + isLineageRoute: boolean; +} + +function Main({ children, lineage, isLineageRoute }: MainProps) { + const { isRunResultOpen, isHistoryOpen, closeRunResult } = + useRecceActionContext(); + const { data: flag } = useRecceServerFlag(); + const pathname = usePathname(); + + const _isRunResultOpen = isRunResultOpen && !pathname.startsWith("/checks"); + const _isHistoryOpen = isHistoryOpen && !pathname.startsWith("/checks"); + + return ( + + {_isHistoryOpen && } + + }> + + {/* + * Lineage parallel route - always mounted but visibility controlled + * This replaces the old RouteAlwaysMount pattern + */} + + {lineage} + + + {/* Other route content */} + {!isLineageRoute && children} + + + {_isRunResultOpen ? ( + + ) : ( + + )} + + + ); +} diff --git a/js/app/Providers.tsx b/js/app/Providers.tsx index 48c4e0162..dd02046fb 100644 --- a/js/app/Providers.tsx +++ b/js/app/Providers.tsx @@ -1,26 +1,36 @@ +/** + * Application Providers + * + * Wraps the application with necessary context providers. + * Updated to remove Wouter Router - now using Next.js App Router. + */ + "use client"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactNode } from "react"; -import { Router } from "wouter"; import { Provider } from "@/components/ui/provider"; import { Toaster } from "@/components/ui/toaster"; import { reactQueryClient } from "@/lib/api/axiosClient"; import { IdleTimeoutProvider } from "@/lib/hooks/IdleTimeoutContext"; import RecceContextProvider from "@/lib/hooks/RecceContextProvider"; -import { useHashLocation } from "@/lib/hooks/useHashLocation"; +import { MainLayout } from "./MainLayout"; + +interface ProvidersProps { + children: ReactNode; + /** Parallel route slot for lineage page */ + lineage: ReactNode; +} -export default function Providers({ children }: { children: ReactNode }) { +export default function Providers({ children, lineage }: ProvidersProps) { return ( - - - {children} - - - + + {children} + + diff --git a/js/src/components/check/CheckPage.tsx b/js/app/checks/page.tsx similarity index 66% rename from js/src/components/check/CheckPage.tsx rename to js/app/checks/page.tsx index 43a5fc9f7..16b865bde 100644 --- a/js/src/components/check/CheckPage.tsx +++ b/js/app/checks/page.tsx @@ -1,24 +1,75 @@ -import "react-data-grid/lib/styles.css"; -import { Box, Center, Flex, Separator, VStack } from "@chakra-ui/react"; +"use client"; + +import { + Box, + Center, + Flex, + Separator, + Spinner, + VStack, +} from "@chakra-ui/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import React, { useCallback, useEffect, useState } from "react"; -import { Route, Switch, useLocation, useRoute } from "wouter"; +import { useSearchParams } from "next/navigation"; +import React, { + ReactNode, + Suspense, + useCallback, + useEffect, + useState, +} from "react"; +import { StateImporter } from "@/components/app/StateImporter"; +import { CheckDetail } from "@/components/check/CheckDetail"; +import { CheckEmptyState } from "@/components/check/CheckEmptyState"; +import { CheckList } from "@/components/check/CheckList"; +import { HSplit } from "@/components/split/Split"; import { cacheKeys } from "@/lib/api/cacheKeys"; import { listChecks, reorderChecks } from "@/lib/api/checks"; import { useRecceCheckContext } from "@/lib/hooks/RecceCheckContext"; -import { StateImporter } from "../app/StateImporter"; -import { HSplit } from "../split/Split"; -import { CheckDetail } from "./CheckDetail"; -import { CheckEmptyState } from "./CheckEmptyState"; -import { CheckList } from "./CheckList"; - -export const CheckPage = () => { - const [, setLocation] = useLocation(); - const [, params] = useRoute("/checks/:checkId"); +import { useAppLocation } from "@/lib/hooks/useAppRouter"; + +/** + * Wrapper component that handles the Suspense boundary for useSearchParams + */ +export default function CheckPageWrapper(): ReactNode { + return ( + }> + + + ); +} + +/** + * Loading fallback - shows minimal UI while search params are being read + */ +function CheckPageLoading(): ReactNode { + return ( + + +
+ +
+
+ +
+ +
+
+
+ ); +} + +function CheckPageContent(): ReactNode { + const [, setLocation] = useAppLocation(); + const searchParams = useSearchParams(); + const checkId = searchParams.get("id"); const { latestSelectedCheckId, setLatestSelectedCheckId } = useRecceCheckContext(); const queryClient = useQueryClient(); - const selectedItem = params?.checkId; + const selectedItem = checkId; useEffect(() => { if (selectedItem) { @@ -40,7 +91,7 @@ export const CheckPage = () => { const handleSelectItem = useCallback( (checkId: string) => { - setLocation(`/checks/${checkId}`); + setLocation(`/checks/?id=${checkId}`); }, [setLocation], ); @@ -85,10 +136,10 @@ export const CheckPage = () => { if (!selectedItem && checks.length > 0) { if (latestSelectedCheckId) { - setLocation(`/checks/${latestSelectedCheckId}`); + setLocation(`/checks/?id=${latestSelectedCheckId}`); } else { // If no check is selected, select the first one by default - setLocation(`/checks/${checks[0].check_id}`); + setLocation(`/checks/?id=${checks[0].check_id}`); } } }, [status, selectedItem, checks, setLocation, latestSelectedCheckId]); @@ -165,20 +216,14 @@ export const CheckPage = () => { - - - {(params) => { - return ( - - ); - }} - - + {selectedItem && ( + + )} ); -}; +} diff --git a/js/app/error.tsx b/js/app/error.tsx new file mode 100644 index 000000000..8da887b2d --- /dev/null +++ b/js/app/error.tsx @@ -0,0 +1,81 @@ +/** + * Next.js App Router Error Boundary + * + * This file handles runtime errors within route segments. + * It automatically wraps the route segment in a React Error Boundary. + * + * @see https://nextjs.org/docs/app/building-your-application/routing/error-handling + */ + +"use client"; + +import { Box, Button, Center, Flex, Heading } from "@chakra-ui/react"; +import * as Sentry from "@sentry/nextjs"; +import { useEffect } from "react"; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +// biome-ignore lint/suspicious/noShadowRestrictedNames: https://nextjs.org/docs/app/api-reference/file-conventions/error +export default function Error({ error, reset }: ErrorProps) { + useEffect(() => { + // Log the error to Sentry + Sentry.captureException(error, { + tags: { + errorBoundary: "app", + digest: error.digest, + }, + extra: { + componentStack: error.stack, + }, + }); + + // Also log to console for development + console.error("App Error Boundary caught error:", error); + }, [error]); + + return ( +
+ + + You have encountered an error + + + + {error.message || String(error)} + + + {error.digest && ( + + Error ID: {error.digest} + + )} + + + +
+ ); +} diff --git a/js/app/global-error.tsx b/js/app/global-error.tsx new file mode 100644 index 000000000..8acf8fc01 --- /dev/null +++ b/js/app/global-error.tsx @@ -0,0 +1,114 @@ +/** + * Next.js Global Error Boundary + * + * This file handles errors in the root layout.tsx. + * It must include its own and tags since it replaces + * the root layout when active. + * + * @see https://nextjs.org/docs/app/building-your-application/routing/error-handling#handling-errors-in-root-layouts + */ + +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { useEffect } from "react"; + +interface GlobalErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function GlobalError({ error, reset }: GlobalErrorProps) { + useEffect(() => { + // Log the error to Sentry with high priority + Sentry.captureException(error, { + level: "fatal", + tags: { + errorBoundary: "global", + digest: error.digest, + }, + extra: { + componentStack: error.stack, + }, + }); + + console.error("Global Error Boundary caught error:", error); + }, [error]); + + return ( + + +
+
+

+ Something went wrong +

+ +

+ {error.message || "An unexpected error occurred"} +

+ + {error.digest && ( +

+ Error ID: {error.digest} +

+ )} + + +
+
+ + + ); +} diff --git a/js/app/layout.tsx b/js/app/layout.tsx index 71d1b67e5..350a78062 100644 --- a/js/app/layout.tsx +++ b/js/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./global.css"; import { GoogleTagManager } from "@next/third-parties/google"; import Providers from "app/Providers"; +import Script from "next/script"; import { ReactNode } from "react"; const GTM_ID = process.env.GTM_ID; @@ -10,14 +11,36 @@ export const metadata: Metadata = { description: "Recce: Data validation toolkit for comprehensive PR review", }; -export default function RootLayout({ children }: { children: ReactNode }) { +interface RootLayoutProps { + children: ReactNode; + /** Parallel route slot from @lineage folder */ + lineage: ReactNode; +} + +export default function RootLayout({ + children, + lineage, +}: RootLayoutProps): ReactNode { return ( {GTM_ID != null && GTM_ID.trim().length > 0 && ( )} + {/* Handle legacy hashbang URL redirects */} +