diff --git a/src/App.tsx b/src/App.tsx index a3d832ba3..ff22b004e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,12 @@ import { import { Canary, Icon } from "./components"; import AuthProviderWrapper from "./components/Authentication/AuthProviderWrapper"; import { ErrorBoundary } from "./components/ErrorBoundary"; +import { HomepageRedirect } from "./components/HomepageRedirect"; +import { + DASHBOARD_VIEW_PROPERTY, + FALLBACK_VIEW_NAME, + UUID_REGEX +} from "./components/dashboardViewConstants"; import { withAuthorizationAccessCheck } from "./components/Permissions/AuthorizationAccessCheck"; import { SchemaResource } from "./components/SchemaResourcePage/SchemaResource"; import { @@ -46,7 +52,6 @@ import { features } from "./services/permissions/features"; import { getViewsForSidebar, ViewSummary } from "./api/services/views"; import { Head } from "./ui/Head"; import { LogsIcon } from "./ui/Icons/LogsIcon"; -import { TopologyIcon } from "./ui/Icons/TopologyIcon"; import { SidebarLayout } from "./ui/Layout/SidebarLayout"; import FullPageSkeletonLoader from "./ui/SkeletonLoader/FullPageSkeletonLoader"; import { ToasterWithCloseButton } from "./ui/ToasterWithCloseButton"; @@ -301,13 +306,6 @@ export type NavigationItems = { }[]; const navigation: NavigationItems = [ - { - name: "Dashboard", - href: "/topology", - icon: TopologyIcon, - featureName: features.topology, - resourceName: tables.database - }, { name: "Health", href: "/health", @@ -506,7 +504,7 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) { return ( - } /> + } /> v.name === FALLBACK_VIEW_NAME); + } + + if (UUID_REGEX.test(dashboardViewValue)) { + return views.find((v) => v.id === dashboardViewValue); + } + + if (dashboardViewValue.includes("/")) { + const [namespace, name] = dashboardViewValue.split("/", 2); + return views.find((v) => v.namespace === namespace && v.name === name); + } + + return views.find((v) => v.name === dashboardViewValue); +} + function useDynamicNavigation() { + const { featureFlags } = useFeatureFlagsContext(); const { data: views = [] } = useQuery({ queryKey: ["views-sidebar"], queryFn: getViewsForSidebar, staleTime: 5 * 60 * 1000 }); + const dashboardViewValue = featureFlags.find( + (f) => f.name === DASHBOARD_VIEW_PROPERTY + )?.value; + return useMemo(() => { - const viewsNavigationItems = views.map((view) => ({ - name: view.title || view.name, - href: `/views/${view.id}`, - icon: ({ className }: { className?: string }) => ( - - ), - featureName: features.views, - resourceName: tables.views - })); + const dashboardView = findDashboardView(views, dashboardViewValue); + + const viewsNavigationItems = views + .filter((view) => view.id !== dashboardView?.id) + .map((view) => ({ + name: view.title || view.name, + href: `/views/${view.id}`, + icon: ({ className }: { className?: string }) => ( + + ), + featureName: features.views, + resourceName: tables.views + })); + + const dashboardNavItem = dashboardView + ? [ + { + name: dashboardView.title || dashboardView.name, + href: `/views/${dashboardView.id}`, + icon: ({ className }: { className?: string }) => ( + + ), + featureName: features.views, + resourceName: tables.views + } + ] + : []; - const navigationWithViews = [...navigation, ...viewsNavigationItems]; - return navigationWithViews; - }, [views]); + return [...dashboardNavItem, ...navigation, ...viewsNavigationItems]; + }, [views, dashboardViewValue]); } function SidebarWrapper() { diff --git a/src/components/HomepageRedirect.tsx b/src/components/HomepageRedirect.tsx new file mode 100644 index 000000000..14cc67d5e --- /dev/null +++ b/src/components/HomepageRedirect.tsx @@ -0,0 +1,61 @@ +// ABOUTME: Resolves the homepage destination based on a configured view property, +// ABOUTME: a well-known view name, or falls back to the health page. + +import { useQuery } from "@tanstack/react-query"; +import { Navigate } from "react-router-dom"; +import { + getViewIdByName, + getViewIdByNamespaceAndName +} from "../api/services/views"; +import { useFeatureFlagsContext } from "../context/FeatureFlagsContext"; +import FullPageSkeletonLoader from "../ui/SkeletonLoader/FullPageSkeletonLoader"; + +import { + DASHBOARD_VIEW_PROPERTY, + FALLBACK_VIEW_NAME, + UUID_REGEX +} from "./dashboardViewConstants"; + +async function resolveViewId(value: string): Promise { + if (value.includes("/")) { + const [namespace, name] = value.split("/", 2); + return getViewIdByNamespaceAndName(namespace, name); + } + return getViewIdByName(value); +} + +export function HomepageRedirect() { + const { featureFlags } = useFeatureFlagsContext(); + + const dashboardViewValue = featureFlags.find( + (f) => f.name === DASHBOARD_VIEW_PROPERTY + )?.value; + + const isUUID = dashboardViewValue && UUID_REGEX.test(dashboardViewValue); + + const { data: redirectPath, isLoading } = useQuery({ + queryKey: ["homepage-redirect", dashboardViewValue], + queryFn: async () => { + if (dashboardViewValue) { + const viewId = await resolveViewId(dashboardViewValue); + if (viewId) return `/views/${viewId}`; + return "/health"; + } + + const viewId = await getViewIdByName(FALLBACK_VIEW_NAME); + if (viewId) return `/views/${viewId}`; + return "/health"; + }, + enabled: !isUUID + }); + + if (isUUID) { + return ; + } + + if (isLoading || !redirectPath) { + return ; + } + + return ; +} diff --git a/src/components/__tests__/HomepageRedirect.unit.test.tsx b/src/components/__tests__/HomepageRedirect.unit.test.tsx new file mode 100644 index 000000000..3e4d62653 --- /dev/null +++ b/src/components/__tests__/HomepageRedirect.unit.test.tsx @@ -0,0 +1,169 @@ +// ABOUTME: Tests for HomepageRedirect component that resolves the homepage +// ABOUTME: destination based on properties and view lookups. + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { HomepageRedirect } from "../HomepageRedirect"; +import { FeatureFlagsContext } from "../../context/FeatureFlagsContext"; +import type { FeatureFlagsState } from "../../context/FeatureFlagsContext"; +import type { FeatureFlag } from "../../services/permissions/permissionsService"; +import { DASHBOARD_VIEW_PROPERTY } from "../dashboardViewConstants"; + +import { + getViewIdByName, + getViewIdByNamespaceAndName +} from "../../api/services/views"; + +jest.mock("../../api/services/views", () => ({ + getViewIdByName: jest.fn(), + getViewIdByNamespaceAndName: jest.fn() +})); + +const mockedGetViewIdByName = getViewIdByName as jest.MockedFunction< + typeof getViewIdByName +>; +const mockedGetViewIdByNamespaceAndName = + getViewIdByNamespaceAndName as jest.MockedFunction< + typeof getViewIdByNamespaceAndName + >; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false } + } + }); +} + +function buildFeatureFlagsState( + featureFlags: FeatureFlag[] = [] +): FeatureFlagsState { + return { + featureFlags, + featureFlagsLoaded: true, + refreshFeatureFlags: () => {}, + isFeatureDisabled: () => false + }; +} + +function renderWithProviders(featureFlags: FeatureFlag[] = []) { + const queryClient = createQueryClient(); + return render( + + + + + } /> + Health} + /> + View} + /> + + + + + ); +} + +function dashboardViewFlag(value: string): FeatureFlag { + return { + name: DASHBOARD_VIEW_PROPERTY, + value, + description: "", + source: "", + type: "" + }; +} + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe("HomepageRedirect", () => { + it("redirects to /views/{uuid} when property is a UUID", async () => { + const uuid = "550e8400-e29b-41d4-a716-446655440000"; + renderWithProviders([dashboardViewFlag(uuid)]); + + await waitFor(() => { + expect(screen.getByTestId("view-page")).toBeInTheDocument(); + }); + + expect(mockedGetViewIdByName).not.toHaveBeenCalled(); + expect(mockedGetViewIdByNamespaceAndName).not.toHaveBeenCalled(); + }); + + it("looks up by namespace/name when property contains a slash", async () => { + const viewId = "aaa-bbb-ccc"; + mockedGetViewIdByNamespaceAndName.mockResolvedValue(viewId); + + renderWithProviders([dashboardViewFlag("my-namespace/my-view")]); + + await waitFor(() => { + expect(screen.getByTestId("view-page")).toBeInTheDocument(); + }); + + expect(mockedGetViewIdByNamespaceAndName).toHaveBeenCalledWith( + "my-namespace", + "my-view" + ); + }); + + it("looks up by name when property is a plain string", async () => { + const viewId = "ddd-eee-fff"; + mockedGetViewIdByName.mockResolvedValue(viewId); + + renderWithProviders([dashboardViewFlag("my-dashboard")]); + + await waitFor(() => { + expect(screen.getByTestId("view-page")).toBeInTheDocument(); + }); + + expect(mockedGetViewIdByName).toHaveBeenCalledWith("my-dashboard"); + }); + + it("falls back to mission-control-dashboard view when no property is set", async () => { + const viewId = "ggg-hhh-iii"; + mockedGetViewIdByName.mockResolvedValue(viewId); + + renderWithProviders([]); + + await waitFor(() => { + expect(screen.getByTestId("view-page")).toBeInTheDocument(); + }); + + expect(mockedGetViewIdByName).toHaveBeenCalledWith( + "mission-control-dashboard" + ); + }); + + it("falls back to /health when no property and no mission-control-dashboard view", async () => { + mockedGetViewIdByName.mockResolvedValue(undefined); + + renderWithProviders([]); + + await waitFor(() => { + expect(screen.getByTestId("health-page")).toBeInTheDocument(); + }); + + expect(mockedGetViewIdByName).toHaveBeenCalledWith( + "mission-control-dashboard" + ); + }); + + it("falls back to /health when property name lookup fails", async () => { + mockedGetViewIdByName.mockResolvedValue(undefined); + + renderWithProviders([dashboardViewFlag("nonexistent-view")]); + + await waitFor(() => { + expect(screen.getByTestId("health-page")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/dashboardViewConstants.ts b/src/components/dashboardViewConstants.ts new file mode 100644 index 000000000..94ea3802f --- /dev/null +++ b/src/components/dashboardViewConstants.ts @@ -0,0 +1,9 @@ +// ABOUTME: Shared constants for resolving the dashboard view from properties. +// ABOUTME: Used by HomepageRedirect and the sidebar navigation. + +export const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export const FALLBACK_VIEW_NAME = "mission-control-dashboard"; + +export const DASHBOARD_VIEW_PROPERTY = "defaults.dashboard_view"; diff --git a/src/context/FeatureFlagsContext.tsx b/src/context/FeatureFlagsContext.tsx index a9a215bd2..71d96da42 100644 --- a/src/context/FeatureFlagsContext.tsx +++ b/src/context/FeatureFlagsContext.tsx @@ -25,7 +25,7 @@ const initialState: FeatureFlagsState = { isFeatureDisabled: (_) => false }; -const FeatureFlagsContext = createContext(initialState); +export const FeatureFlagsContext = createContext(initialState); export const FeatureFlagsContextProvider = ({ children