From fac242815bb29e386934e39e788a84c12a3892ef Mon Sep 17 00:00:00 2001 From: Justin Bradfield Date: Thu, 6 Aug 2026 13:04:02 -0500 Subject: [PATCH 1/4] console: render environment-not-ready flow in the standard layout The environment-not-ready flow used a stripped custom layout with no navigation, so users without an enabled environment could not see or reach account-scoped pages like App Passwords, License, or Usage & Billing. A blocked or trial-expired organization, by contrast, already rendered the full BaseLayout with those links visible, making the two states inconsistent. Render EnvironmentNotReadyRoutes inside BaseLayout instead. The nav already handles the no-environment state: region-scoped items hide via HideIfEnvironmentDisabled while account-scoped Admin items carry forceShow, so new users now get the same chrome as everyone else. The logo links back to the enable-region flow while no environment is ready. The welcome dialog is suppressed in this flow via a new BaseLayout prop. It pops the moment a region becomes healthy and would cover the flow's own region-ready affordances, blocking the tutorial's "Open console" button (caught by the e2e suite). The region-ready toast is preserved in a headless RegionReadyToast component. Its unmount cleanup reads the unstable toast reference through a ref instead of an eslint-disable, which the react-compiler lint rule no longer accepts. The custom layout file is deleted. Adds tests asserting the Admin nav group renders on the enable-region page with no enabled environment while region-scoped items stay hidden, and that the welcome dialog stays suppressed once a region is healthy. The test provider wrapper now nests ToastProvider inside the router to match the app, so toasts can render router Links. Co-Authored-By: Claude Fable 5 --- console/src/layouts/BaseLayout.tsx | 8 +- .../EnvironmentNotReadyRoutes.test.tsx | 27 +++ .../EnvironmentNotReadyRoutes.tsx | 87 ++++++++- .../platform/environment-not-ready/Layout.tsx | 172 ------------------ console/src/test/utils.tsx | 4 +- 5 files changed, 120 insertions(+), 178 deletions(-) delete mode 100644 console/src/platform/environment-not-ready/Layout.tsx diff --git a/console/src/layouts/BaseLayout.tsx b/console/src/layouts/BaseLayout.tsx index abb13f5409e17..d4b56e2016c71 100644 --- a/console/src/layouts/BaseLayout.tsx +++ b/console/src/layouts/BaseLayout.tsx @@ -54,6 +54,12 @@ import { FIXED_TOP_BAR_Z_INDEX, MAIN_CONTENT_Z_INDEX } from "./zIndex"; export interface BaseLayoutProps { children?: React.ReactNode; containerProps?: FlexProps; + /** + * Suppresses the welcome dialog that pops when a region first becomes + * healthy. Used by flows with their own region-ready affordances, so the + * dialog doesn't cover them. + */ + hideWelcomeDialog?: boolean; navBarOverride?: React.FunctionComponent; sectionNav?: React.ReactNode; } @@ -98,7 +104,7 @@ export const BaseLayout = (props: BaseLayoutProps) => { data-testid="page-layout" {...props.containerProps} > - + {!props.hideWelcomeDialog && } { ); }); }); + + it("shows account-level navigation without an enabled environment", async () => { + await renderRoutes(["/enable-region"]); + // Admin items are account-scoped and must stay reachable while no + // environment is enabled. + expect(await screen.findByText("Admin")).toBeInTheDocument(); + expect(screen.getByText("App Passwords")).toBeInTheDocument(); + expect(screen.getByText("Usage & Billing")).toBeInTheDocument(); + // Region-scoped items are hidden until an environment is ready. + expect(screen.queryByText("Clusters")).not.toBeInTheDocument(); + expect(screen.queryByText("SQL Shell")).not.toBeInTheDocument(); + }); + + it("does not show the welcome dialog when a region becomes ready", async () => { + // The dialog would cover the flow's own region-ready affordances (the + // toast and the tutorial's "Open console" button). + await renderRoutes(["/creating-environment"], ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", healthyEnvironment), + ); + expect( + await screen.findByText("We’re creating your environment"), + ).toBeVisible(); + expect( + screen.queryByTestId("welcome-dialog-close-button"), + ).not.toBeInTheDocument(); + }); }); diff --git a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx index 34c8214aec1c5..5daf2d24a9e33 100644 --- a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx +++ b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx @@ -7,24 +7,103 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. +import { Text, VStack } from "@chakra-ui/react"; +import { useAtomValue } from "jotai"; import React from "react"; -import { Navigate, Route } from "react-router-dom"; +import { Link, Navigate, Route } from "react-router-dom"; +import TextLink from "~/components/TextLink"; import { User } from "~/external-library-wrappers/frontegg"; +import { useToast } from "~/hooks/useToast"; +import { BaseLayout } from "~/layouts/BaseLayout"; +import { regionPath } from "~/platform/routeHelpers"; import { SentryRoutes } from "~/sentry"; +import { + currentRegionIdAtom, + useEnvironmentsWithHealth, + useRegionSlug, +} from "~/store/environments"; import EnableRegion from "./EnableRegion"; -import { EnvironmentNotReadyLayout } from "./Layout"; import { OnboardingSteps } from "./OnboardingSteps"; +const REGION_READY_TOAST_ID = "region-ready-toast"; + +export const RegionReadyToastBody = (props: { + currentRegionId: string; + regionPath: string; +}) => { + return ( + + {props.currentRegionId} is ready! + + Go to Materialize Console → + + + ); +}; + +/** + * Pops a toast when the current region becomes healthy while the user is + * still in the environment-not-ready flow. Renders nothing. + */ +const RegionReadyToast = () => { + const toast = useToast(); + const regionSlug = useRegionSlug(); + const environments = useEnvironmentsWithHealth(); + const currentRegionId = useAtomValue(currentRegionIdAtom); + const currentEnvironment = environments.get(currentRegionId); + + // The toast reference isn't stable, so the unmount cleanup reads it through + // a ref instead of depending on it directly. + const toastRef = React.useRef(toast); + React.useEffect(() => { + toastRef.current = toast; + }, [toast]); + + React.useEffect(() => { + if ( + currentEnvironment && + currentEnvironment.state === "enabled" && + currentEnvironment.status.health === "healthy" && + !toast.isActive(REGION_READY_TOAST_ID) + ) { + toast({ + id: REGION_READY_TOAST_ID, + duration: null, // keep it open + position: "top-right", + description: ( + + ), + }); + } + }, [currentEnvironment, currentRegionId, regionSlug, toast]); + + React.useEffect(() => { + return () => { + // Close the toast when this component unmounts + toastRef.current.close(REGION_READY_TOAST_ID); + }; + }, []); + + return null; +}; + export const EnvironmentNotReadyRoutes = ({ user }: { user: User }) => { return ( - + // The welcome dialog is suppressed here because this flow has its own + // region-ready affordances (the toast and the tutorial's "Open console" + // button), which the dialog would cover. + + } /> } /> } /> - + ); }; diff --git a/console/src/platform/environment-not-ready/Layout.tsx b/console/src/platform/environment-not-ready/Layout.tsx deleted file mode 100644 index efc4956c23d5a..0000000000000 --- a/console/src/platform/environment-not-ready/Layout.tsx +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright Materialize, Inc. and contributors. All rights reserved. -// -// Use of this software is governed by the Business Source License -// included in the LICENSE file. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0. - -import { Flex, HStack, Text, useTheme, VStack } from "@chakra-ui/react"; -import { useAtomValue } from "jotai"; -import React from "react"; -import { Link } from "react-router-dom"; - -import { MaterializeLogo } from "~/components/MaterializeLogo"; -import TextLink from "~/components/TextLink"; -import { User } from "~/external-library-wrappers/frontegg"; -import { useToast } from "~/hooks/useToast"; -import EnvironmentSelect from "~/layouts/EnvironmentSelect"; -import PageFooter from "~/layouts/PageFooter"; -import ProfileDropdown from "~/layouts/ProfileDropdown"; -import { NAVBAR_Z_INDEX } from "~/layouts/zIndex"; -import { regionPath } from "~/platform/routeHelpers"; -import { - currentRegionIdAtom, - useEnvironmentsWithHealth, - useRegionSlug, -} from "~/store/environments"; -import { MaterializeTheme } from "~/theme"; - -const REGION_READY_TOAST_ID = "region-ready-toast"; - -export const NAVBAR_HEIGHT = "16"; - -export const RegionReadyToastBody = (props: { - currentRegionId: string; - regionPath: string; -}) => { - return ( - - {props.currentRegionId} is ready! - - Go to Materialize Console → - - - ); -}; - -export const EnvironmentNotReadyStatus = ({ user }: { user: User }) => { - const toast = useToast(); - const regionSlug = useRegionSlug(); - const environments = useEnvironmentsWithHealth(); - const currentRegionId = useAtomValue(currentRegionIdAtom); - const currentEnvironment = environments.get(currentRegionId); - const currentRegionReady = - currentEnvironment && - "status" in currentEnvironment && - currentEnvironment.status.health === "healthy"; - - const anyOtherRegionReady = React.useMemo( - () => - Array.from(environments.entries()).some(([regionId, env]) => { - if (regionId === currentRegionId) return false; - return ( - env && - env.state === "enabled" && - (env.status.health === "healthy" || env.status.health === "blocked") - ); - }), - [currentRegionId, environments], - ); - - React.useEffect(() => { - if ( - currentEnvironment && - currentEnvironment.state === "enabled" && - currentEnvironment.status.health === "healthy" && - !toast.isActive(REGION_READY_TOAST_ID) - ) { - toast({ - id: REGION_READY_TOAST_ID, - duration: null, // keep it open - position: "top-right", - description: ( - - ), - }); - } - }, [currentEnvironment, currentRegionId, regionSlug, toast]); - - React.useEffect(() => { - return () => { - // Close to toast when this component unmounts - toast.close(REGION_READY_TOAST_ID); - }; - // The toast reference isn't stable, including it in the dependency array leads to - // edge cases where the toast never shows up. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (!currentEnvironment) return; - - return ( - - - - - Materialize - - - {!currentRegionReady && anyOtherRegionReady && ( - - )} - - ); -}; - -export const EnvironmentNotReadyLayout = (props: { - children: React.ReactNode; - user: User; -}) => { - const { colors } = useTheme(); - - return ( - - - - - - - {props.children} - - - - ); -}; diff --git a/console/src/test/utils.tsx b/console/src/test/utils.tsx index b050ee5d43be6..2a4e8e38d18aa 100644 --- a/console/src/test/utils.tsx +++ b/console/src/test/utils.tsx @@ -206,10 +206,12 @@ export const createProviderWrapper = async ({ + {/* Inside the router to match the app's provider nesting, + so toasts can render router Links. */} + - From a62649730d486659f0ea6b84639e3efafa490c9e Mon Sep 17 00:00:00 2001 From: Justin Bradfield Date: Thu, 6 Aug 2026 21:03:33 -0500 Subject: [PATCH 2/4] console: give SQL errors the boot-window grace in the health probe The environment health probe treated a SQL-level error response as an immediate "crashed", while connection failures got a grace window of maxBootDuration after the region was enabled. A freshly provisioned environmentd can respond with errors before it has fully initialized, and the region API only reports provisioning state, not whether environmentd is serving queries, so the probe is the only readiness signal. Since isEnvironmentReady counts "crashed" as ready and EnableRegion routes crashed environments to the console home, one transient error during boot dropped users into a console that could not serve queries yet. Treat SQL errors like connection failures: "booting" within the boot window, "crashed" after it. Co-Authored-By: Claude Fable 5 --- console/src/store/environments.test.ts | 12 +++++++++++- console/src/store/environments.ts | 20 +++++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/console/src/store/environments.test.ts b/console/src/store/environments.test.ts index 0a5e2bec5692a..2d251c7054745 100644 --- a/console/src/store/environments.test.ts +++ b/console/src/store/environments.test.ts @@ -66,9 +66,19 @@ describe("store/environments", () => { expect(result.health).toEqual("healthy"); }); - it("should return crashed when there is an error", async () => { + it("should return booting for a SQL error while still within the boot window", async () => { server.use(badRequestHandler); const result = await fetchEnvironmentHealth(enabledEnvironment); + expect(result.health).toEqual("booting"); + }); + + it("should return crashed for a SQL error after the boot window", async () => { + server.use(badRequestHandler); + const result = await fetchEnvironmentHealth( + enabledEnvironment, + 10_000, + { seconds: 0.001 }, // 1ms max boot time (already exceeded) + ); expect(result.health).toEqual("crashed"); const { errors } = result as { errors?: EnvironmentError[] }; expect(errors).toEqual([ diff --git a/console/src/store/environments.ts b/console/src/store/environments.ts index ab0b652e81648..614bb68c9f07b 100644 --- a/console/src/store/environments.ts +++ b/console/src/store/environments.ts @@ -423,6 +423,14 @@ export const useEnvironmentsWithHealth = () => { const defaultTimeout = 10_000; // 10 seconds const maxBootDuration = { minutes: 15 }; + +/** Whether an environment enabled at `enabledAt` is still within its boot grace window. */ +function withinBootWindow( + enabledAt: EnabledEnvironment["enabledAt"], + maxBoot: Duration, +) { + return new Date() <= add(new Date(enabledAt), maxBoot); +} // A made up enabled at time only used during impersonation and self-managed, // since we don't know when the environment was enabled. const fakeEnabledAt = sub(new Date(), { @@ -508,6 +516,14 @@ export const fetchEnvironmentHealth = async ( errors, }; } + // A fresh environmentd can respond with errors before it has fully + // initialized, so a SQL-level error gets the same boot-window grace + // as an unreachable environment. Reporting "crashed" here would count + // as ready (isEnvironmentReady) and route users into a console that + // cannot serve queries yet. + if (withinBootWindow(environment.enabledAt, maxBoot)) { + return { health: "booting", errors: [] }; + } errors.push({ message: "Environmentd health check failed", }); @@ -521,9 +537,7 @@ export const fetchEnvironmentHealth = async ( return { health: "healthy", version, errors: [] }; } } catch (e) { - const enabledAt = new Date(environment.enabledAt); - const cutoff = add(enabledAt, maxBoot); - if (new Date() > cutoff) { + if (!withinBootWindow(environment.enabledAt, maxBoot)) { const errors: EnvironmentError[] = []; errors.push({ message: `Environment not resolvable for more than ${formatDuration( From abc97b1eecf7770c7f9e6a48b1fd1d06d8078ad2 Mon Sep 17 00:00:00 2001 From: Justin Bradfield Date: Thu, 6 Aug 2026 21:26:41 -0500 Subject: [PATCH 3/4] console: keep the environment-not-ready nav account-only The nav in the environment-not-ready flow was gated on environment health, so a transient health reading could flash the full sidebar and object-creation entry points while nothing was usable yet. Gate it on the flow instead: a new accountOnly nav mode renders only the account-scoped (forceShow) items and hides the Create New button, and EnvironmentNotReadyRoutes turns it on. The full sidebar appears only once the user leaves the flow for a working console. Also restyle the tutorial slides to center in the layout's content area like the enable-region page, instead of the absolute viewport positioning they used under the old standalone layout. Adds a test asserting the nav stays account-only in this flow even when the environment reports crashed. Co-Authored-By: Claude Fable 5 --- console/src/layouts/BaseLayout.tsx | 7 +++++- console/src/layouts/NavBar.tsx | 19 +++++++++++++--- console/src/layouts/NavBar/NavMenu.tsx | 17 +++++++++++--- .../EnvironmentNotReadyRoutes.test.tsx | 17 +++++++++++++- .../EnvironmentNotReadyRoutes.tsx | 2 +- .../environment-not-ready/OnboardingSteps.tsx | 22 ++++++++++--------- 6 files changed, 65 insertions(+), 19 deletions(-) diff --git a/console/src/layouts/BaseLayout.tsx b/console/src/layouts/BaseLayout.tsx index d4b56e2016c71..1c520d8870c89 100644 --- a/console/src/layouts/BaseLayout.tsx +++ b/console/src/layouts/BaseLayout.tsx @@ -60,6 +60,8 @@ export interface BaseLayoutProps { * dialog doesn't cover them. */ hideWelcomeDialog?: boolean; + /** Renders only account-scoped navigation. See NavBarProps.accountOnly. */ + accountOnlyNav?: boolean; navBarOverride?: React.FunctionComponent; sectionNav?: React.ReactNode; } @@ -112,7 +114,10 @@ export const BaseLayout = (props: BaseLayoutProps) => { flexGrow="1" minHeight="0" > - + { export interface NavBarProps { isCollapsed: boolean; + /** + * Renders only account-scoped navigation (the Admin group), hiding + * region-scoped items and object creation. Used by flows where no + * environment is usable yet, such as environment-not-ready. + */ + accountOnly?: boolean; } -export const NavBar = ({ isCollapsed }: NavBarProps) => { +export const NavBar = ({ isCollapsed, accountOnly }: NavBarProps) => { const { isOpen: isConnectModalOpen, onClose: onCloseConnectModal, @@ -224,6 +230,7 @@ export const NavBar = ({ isCollapsed }: NavBarProps) => { offsetY={navBarContainerHeight} runtimeConfig={runtimeConfig} isMobile={true} + accountOnly={accountOnly} /> )} selfManagedConfigElement={ @@ -231,22 +238,28 @@ export const NavBar = ({ isCollapsed }: NavBarProps) => { closeMenu={closeMobileNav} offsetY={navBarContainerHeight} isMobile={true} + accountOnly={accountOnly} /> } /> )} - + {!accountOnly && } ( )} selfManagedConfigElement={ - + } /> {!isMobile && } diff --git a/console/src/layouts/NavBar/NavMenu.tsx b/console/src/layouts/NavBar/NavMenu.tsx index 6a2ea3c63615f..04e78b0ac4b4f 100644 --- a/console/src/layouts/NavBar/NavMenu.tsx +++ b/console/src/layouts/NavBar/NavMenu.tsx @@ -347,6 +347,8 @@ const NavMenuMobile = (props: { type BaseCloudNavMenuProps = { runtimeConfig: CloudRuntimeConfig; + /** Renders only account-scoped (forceShow) items. */ + accountOnly?: boolean; }; type MobileNavMenuProps = { @@ -365,9 +367,12 @@ export const CloudNavMenu = ( | (BaseCloudNavMenuProps & DesktopNavMenuProps) | (BaseCloudNavMenuProps & MobileNavMenuProps), ) => { - const items = useCloudNavMenuItems({ + const allItems = useCloudNavMenuItems({ runtimeConfig: props.runtimeConfig, }); + const items = props.accountOnly + ? allItems.filter((item) => item.forceShow) + : allItems; if (props.isMobile) { return ; @@ -377,9 +382,15 @@ export const CloudNavMenu = ( }; export const SelfManagedNavMenu = ( - props: DesktopNavMenuProps | MobileNavMenuProps, + props: (DesktopNavMenuProps | MobileNavMenuProps) & { + /** Renders only account-scoped (forceShow) items. */ + accountOnly?: boolean; + }, ) => { - const items = useSelfManagedNavMenuItems(); + const allItems = useSelfManagedNavMenuItems(); + const items = props.accountOnly + ? allItems.filter((item) => item.forceShow) + : allItems; if (props.isMobile) { return ; diff --git a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx index 46cdbdee32fec..5c1aa6a422c0c 100644 --- a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx +++ b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx @@ -100,9 +100,24 @@ describe("EnvironmentNotReadyRoutes", () => { expect(await screen.findByText("Admin")).toBeInTheDocument(); expect(screen.getByText("App Passwords")).toBeInTheDocument(); expect(screen.getByText("Usage & Billing")).toBeInTheDocument(); - // Region-scoped items are hidden until an environment is ready. + // Region-scoped items and object creation are hidden in this flow. expect(screen.queryByText("Clusters")).not.toBeInTheDocument(); expect(screen.queryByText("SQL Shell")).not.toBeInTheDocument(); + expect(screen.queryByText("Create New")).not.toBeInTheDocument(); + }); + + it("keeps the nav account-only in this flow regardless of environment health", async () => { + // The nav in this flow is gated on the route, not on health state, so a + // transient "crashed" reading during boot cannot flash the full sidebar. + await renderRoutes(["/creating-environment"], ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", { + ...healthyEnvironment, + status: { ...healthyEnvironment.status, health: "crashed" }, + }), + ); + expect(await screen.findByText("Admin")).toBeInTheDocument(); + expect(screen.queryByText("Clusters")).not.toBeInTheDocument(); + expect(screen.queryByText("Create New")).not.toBeInTheDocument(); }); it("does not show the welcome dialog when a region becomes ready", async () => { diff --git a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx index 5daf2d24a9e33..7ba378f4c9083 100644 --- a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx +++ b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx @@ -97,7 +97,7 @@ export const EnvironmentNotReadyRoutes = ({ user }: { user: User }) => { // The welcome dialog is suppressed here because this flow has its own // region-ready affordances (the toast and the tutorial's "Open console" // button), which the dialog would cover. - + } /> diff --git a/console/src/platform/environment-not-ready/OnboardingSteps.tsx b/console/src/platform/environment-not-ready/OnboardingSteps.tsx index 56d91b61d908a..7f4753614c8bb 100644 --- a/console/src/platform/environment-not-ready/OnboardingSteps.tsx +++ b/console/src/platform/environment-not-ready/OnboardingSteps.tsx @@ -58,7 +58,13 @@ export const OnboardingSteps = ({ user }: { user: User }) => { const environmentReady = environment?.state === "enabled" && environment.status.health === "healthy"; return ( - + { base: "center", xl: "space-between", }} - ml={{ base: "0", xl: "144px" }} - position="absolute" - top={{ base: "auto", xl: "50%" }} - right={{ base: "auto", xl: "0" }} - left={{ base: "auto", xl: "0" }} - mt={{ base: "auto", xl: "-300px" }} + px={{ base: 8, xl: 16 }} width="100%" + maxWidth="1500px" > { src={slide.image.src} alt={slide.image.src} height="auto" - maxHeight={{ base: "auto", lg: "400px", xl: "600px" }} - mr={{ base: undefined, xl: "-44px" }} + maxHeight={{ base: "auto", lg: "400px", xl: "540px" }} px={{ base: "8", xl: "0" }} width={{ base: "100%", lg: "auto", xl: "auto" }} + maxWidth={{ xl: "55%" }} /> - + ); }; From ffe172c2caeb3f2c240a5484511eee5ee1e4d84e Mon Sep 17 00:00:00 2001 From: Justin Bradfield Date: Thu, 6 Aug 2026 16:17:59 -0500 Subject: [PATCH 4/4] console: pin the Admin nav group to the bottom of the side panel The Admin group sat directly below the region-scoped nav items, so whenever those items were hidden (no enabled environment, or a blocked organization) it slid up to the top of the panel. Pin it to the bottom instead, so account-scoped links keep a stable position regardless of environment state. Also hide the Create New button while no environment is ready. All of its actions are disabled in that state, so it only added noise to the pared-down panel. Adds NavBar tests for the Admin group ordering and for the pared-down no-environment state. Co-Authored-By: Claude Fable 5 --- console/src/layouts/NavBar.test.tsx | 55 ++++++++++++++++++++++++++ console/src/layouts/NavBar.tsx | 7 +++- console/src/layouts/NavBar/NavMenu.tsx | 43 +++++++++++++++----- 3 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 console/src/layouts/NavBar.test.tsx diff --git a/console/src/layouts/NavBar.test.tsx b/console/src/layouts/NavBar.test.tsx new file mode 100644 index 0000000000000..caf4b966ba6a8 --- /dev/null +++ b/console/src/layouts/NavBar.test.tsx @@ -0,0 +1,55 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { screen } from "@testing-library/react"; +import React from "react"; + +import server from "~/api/mocks/server"; +import { + disabledEnvironment, + healthyEnvironment, + renderComponent, + setFakeEnvironment, +} from "~/test/utils"; + +import { NavBar } from "./NavBar"; + +describe("NavBar", () => { + afterEach(() => { + server.resetHandlers(); + vi.clearAllMocks(); + }); + + it("renders the Admin group after the region-scoped items", async () => { + await renderComponent(, { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", healthyEnvironment), + }); + const clusters = await screen.findByText("Clusters"); + const admin = screen.getByText("Admin"); + // Admin is pinned to the bottom of the menu, so it must come after the + // region-scoped items in document order. + expect( + clusters.compareDocumentPosition(admin) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText("Create New")).toBeInTheDocument(); + }); + + it("hides the create button and region items without an enabled environment", async () => { + await renderComponent(, { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", disabledEnvironment), + }); + expect(await screen.findByText("Admin")).toBeInTheDocument(); + expect(screen.queryByText("Create New")).not.toBeInTheDocument(); + expect(screen.queryByText("Clusters")).not.toBeInTheDocument(); + expect(screen.queryByText("SQL Shell")).not.toBeInTheDocument(); + }); +}); diff --git a/console/src/layouts/NavBar.tsx b/console/src/layouts/NavBar.tsx index 8f334626de234..cad1e7b16ad8a 100644 --- a/console/src/layouts/NavBar.tsx +++ b/console/src/layouts/NavBar.tsx @@ -244,7 +244,11 @@ export const NavBar = ({ isCollapsed, accountOnly }: NavBarProps) => { /> )} - {!accountOnly && } + {!accountOnly && ( + + + + )} ( { /> } /> - {!isMobile && } {!isMobile && !isCollapsed && ( )} diff --git a/console/src/layouts/NavBar/NavMenu.tsx b/console/src/layouts/NavBar/NavMenu.tsx index 04e78b0ac4b4f..7044ce6b0d066 100644 --- a/console/src/layouts/NavBar/NavMenu.tsx +++ b/console/src/layouts/NavBar/NavMenu.tsx @@ -50,6 +50,8 @@ export type NavItemType = { navItems?: NavItemType[]; onClick?: () => void; forceShow?: boolean; + /** Renders the item at the bottom of the nav menu, below a spacer. */ + pinToBottom?: boolean; }; const getNavItems = ({ @@ -144,6 +146,7 @@ const getNavItems = ({ icon: , label: "Admin", forceShow: true, + pinToBottom: true, navItems: [ ...(canViewAppPasswords ? [ @@ -253,9 +256,17 @@ const useSelfManagedNavMenuItems = () => { }; const NavMenu = (props: { isCollapsed?: boolean; items: NavItemType[] }) => { + const topItems = props.items.filter((item) => !item.pinToBottom); + const bottomItems = props.items.filter((item) => item.pinToBottom); return ( - {props.items.map((item) => ( + {topItems.map((item) => ( + + + + ))} + + {bottomItems.map((item) => ( @@ -285,16 +296,30 @@ const NavMenuMobile = (props: { overflowY="auto" > - {props.items.map((item) => ( - - - - ))} + {props.items + .filter((item) => !item.pinToBottom) + .map((item) => ( + + + + ))} + + {props.items + .filter((item) => item.pinToBottom) + .map((item) => ( + + + + ))} +