From e308f426403f1490cfb724f259765bea6a49f5df Mon Sep 17 00:00:00 2001 From: Qindeel Ishtiaq Date: Wed, 30 Sep 2026 19:55:13 -0400 Subject: [PATCH 1/2] console: add redesigned cluster details layout behind a UI preview First of a stack that redesigns the cluster details page. It sits behind the clusterDetailsRedesign UI preview (LaunchDarkly flag cluster-details-page-cns-173), which users opt into per browser from the page header. Without the opt-in the classic page is unchanged. The new page adds a title row with the cluster's size and replica count, and three tabs: Metrics (the existing overview for now), Objects (the existing materialized view, index, source and sink lists under one tab) and Replicas. Replicas is temporary until a configuration drawer manages replicas. Classic tab URLs redirect into the Objects tab, and objects/ URLs redirect to the classic tabs, so shared links work for both groups. The breadcrumbs move to their own module so both pages share them. --- console/src/config/uiPreviews.ts | 4 + .../platform/clusters/ClusterDetail.test.tsx | 206 ++++++++++++++++++ .../src/platform/clusters/ClusterDetail.tsx | 125 +++-------- .../clusters/ClusterDetailBreadcrumbs.tsx | 116 ++++++++++ .../clusters/ClusterPage/ClusterObjects.tsx | 61 ++++++ .../clusters/ClusterPage/ClusterPage.tsx | 117 ++++++++++ .../ClusterPage/clusterObjectTypes.ts | 25 +++ 7 files changed, 557 insertions(+), 97 deletions(-) create mode 100644 console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ClusterObjects.tsx create mode 100644 console/src/platform/clusters/ClusterPage/ClusterPage.tsx create mode 100644 console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts diff --git a/console/src/config/uiPreviews.ts b/console/src/config/uiPreviews.ts index 03bf5e9581953..002f7d1722b15 100644 --- a/console/src/config/uiPreviews.ts +++ b/console/src/config/uiPreviews.ts @@ -20,6 +20,10 @@ export const UI_PREVIEWS = { ldFlag: "usage-metrics-in-cluster-list-CNS121", label: "Cluster list usage metrics", }, + clusterDetailsRedesign: { + ldFlag: "cluster-details-page-cns-173", + label: "Cluster details redesign", + }, } as const satisfies Record; export type UiPreviewKey = keyof typeof UI_PREVIEWS; diff --git a/console/src/platform/clusters/ClusterDetail.test.tsx b/console/src/platform/clusters/ClusterDetail.test.tsx index 80b5a500bda0c..c82e7df764248 100644 --- a/console/src/platform/clusters/ClusterDetail.test.tsx +++ b/console/src/platform/clusters/ClusterDetail.test.tsx @@ -12,6 +12,8 @@ import userEvent from "@testing-library/user-event"; import React from "react"; import { Route, Routes } from "react-router-dom"; +import { UI_PREVIEWS } from "~/config/uiPreviews"; +import { uiPreviewOptInStorageKey } from "~/hooks/useUiPreview"; import { getStore } from "~/jotai"; import { allClusters } from "~/store/allClusters"; import { mockSubscribeState } from "~/test/mockSubscribe"; @@ -20,11 +22,62 @@ import { renderComponent, RenderWithPathname } from "~/test/utils"; import ClusterDetail from "./ClusterDetail"; import { buildClusterServerResponse } from "./clustersTestUtils"; +const flags: Record = {}; + +vi.mock("~/hooks/useFlags", () => ({ + useFlags: () => flags, +})); + vi.mock("~/platform/clusters/ClusterOverview", () => ({ default: function () { return
ClusterOverview component
; }, })); +vi.mock("~/platform/clusters/ClusterReplicas", () => ({ + default: () =>
ClusterReplicas component
, +})); +vi.mock("~/platform/clusters/MaterializedViewsList", () => ({ + default: () =>
MaterializedViewsList component
, +})); +vi.mock("~/platform/clusters/IndexList", () => ({ + default: () =>
IndexList component
, +})); +vi.mock("~/platform/clusters/Sources", () => ({ + default: () =>
Sources component
, +})); +vi.mock("~/platform/clusters/Sinks", () => ({ + default: () =>
Sinks component
, +})); + +const redesignFlag = UI_PREVIEWS.clusterDetailsRedesign.ldFlag; + +const seedClusters = () => + getStore().set( + allClusters, + mockSubscribeState({ + data: [ + buildClusterServerResponse({ id: "u1", name: "default" }), + buildClusterServerResponse({ id: "u2", name: "quickstart" }), + ], + }), + ); + +const renderClusterDetail = (initialPath: string) => + renderComponent( + + + + } /> + + + , + { initialRouterEntries: [initialPath] }, + ); + +const expectPathname = async (pathname: string) => + waitFor(() => + expect(screen.getByTestId("pathname").textContent).toBe(pathname), + ); describe("ClusterRoutes", () => { it("breadcrumb context menu allows switching clusters", async () => { @@ -70,3 +123,156 @@ describe("ClusterRoutes", () => { expect(screen.getByText("ClusterOverview component")).toBeVisible(); }); }); + +describe("ClusterDetail redesign preview", () => { + beforeEach(() => { + localStorage.clear(); + delete flags[redesignFlag]; + seedClusters(); + }); + + describe("when opted in", () => { + beforeEach(() => { + flags[redesignFlag] = true; + localStorage.setItem( + uiPreviewOptInStorageKey("clusterDetailsRedesign"), + "true", + ); + }); + + it("shows the cluster title and the redesigned tabs", async () => { + renderClusterDetail("/u1/default"); + + expect( + await screen.findByRole("heading", { level: 1, name: "default" }), + ).toBeVisible(); + expect(screen.getByText("small")).toBeVisible(); + expect(screen.getByText("1 replica")).toBeVisible(); + expect(screen.getByRole("link", { name: "Metrics" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Objects" })).toBeVisible(); + expect(screen.getByRole("link", { name: "Replicas" })).toBeVisible(); + expect( + screen.queryByRole("link", { name: "Materialized Views" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("ClusterOverview component")).toBeVisible(); + }); + + it("opens the Objects tab on its first object type", async () => { + const user = userEvent.setup(); + renderClusterDetail("/u1/default"); + + await user.click(await screen.findByRole("link", { name: "Objects" })); + + await expectPathname("/u1/default/objects/materialized-views"); + expect( + await screen.findByText("MaterializedViewsList component"), + ).toBeVisible(); + }); + + it("switches object types from the type tabs", async () => { + const user = userEvent.setup(); + renderClusterDetail("/u1/default/objects/materialized-views"); + + await user.click(await screen.findByRole("tab", { name: "Indexes" })); + + await expectPathname("/u1/default/objects/indexes"); + expect(await screen.findByText("IndexList component")).toBeVisible(); + expect( + screen.queryByText("MaterializedViewsList component"), + ).not.toBeInTheDocument(); + }); + + it("returns to Metrics from a nested Objects URL", async () => { + const user = userEvent.setup(); + renderClusterDetail("/u1/default/objects/indexes"); + + await user.click(await screen.findByRole("link", { name: "Metrics" })); + + await expectPathname("/u1/default"); + expect( + await screen.findByText("ClusterOverview component"), + ).toBeVisible(); + }); + + it("redirects classic tab URLs into the Objects tab", async () => { + renderClusterDetail("/u1/default/sinks"); + + await expectPathname("/u1/default/objects/sinks"); + expect(await screen.findByText("Sinks component")).toBeVisible(); + }); + + it("redirects an unknown object type to the first type", async () => { + renderClusterDetail("/u1/default/objects/tables"); + + await expectPathname("/u1/default/objects/materialized-views"); + }); + + it("keeps the current tab when switching clusters", async () => { + const user = userEvent.setup(); + renderClusterDetail("/u1/default/objects/indexes"); + + await user.click( + await screen.findByRole("button", { name: "Navigation actions" }), + ); + await user.click( + await screen.findByRole("menuitem", { name: "quickstart" }), + ); + + await expectPathname("/u2/quickstart/objects/indexes"); + expect( + await screen.findByRole("heading", { level: 1, name: "quickstart" }), + ).toBeVisible(); + }); + + it("switches back to the classic page", async () => { + const user = userEvent.setup(); + renderClusterDetail("/u1/default"); + + await user.click( + await screen.findByRole("button", { + name: /Show classic cluster details page/, + }), + ); + + expect( + await screen.findByRole("link", { name: "Materialized Views" }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { level: 1, name: "default" }), + ).not.toBeInTheDocument(); + }); + }); + + describe("when not opted in", () => { + it("offers the preview on the classic page when the flag is on", async () => { + flags[redesignFlag] = true; + renderClusterDetail("/u1/default"); + + expect( + await screen.findByRole("button", { + name: /Show the new cluster details page/, + }), + ).toBeVisible(); + expect(screen.getByRole("link", { name: "Overview" })).toBeVisible(); + }); + + it("sends an unknown object type to the classic overview", async () => { + renderClusterDetail("/u1/default/objects/tables"); + + await expectPathname("/u1/default"); + }); + + it("opens the first classic object tab from a redesigned Objects tab link", async () => { + renderClusterDetail("/u1/default/objects"); + + await expectPathname("/u1/default/materialized-views"); + }); + + it("redirects redesigned Objects URLs to the classic tabs", async () => { + renderClusterDetail("/u1/default/objects/sinks"); + + await expectPathname("/u1/default/sinks"); + expect(await screen.findByText("Sinks component")).toBeVisible(); + }); + }); +}); diff --git a/console/src/platform/clusters/ClusterDetail.tsx b/console/src/platform/clusters/ClusterDetail.tsx index 1f7eeaa97ea68..e17b241aded08 100644 --- a/console/src/platform/clusters/ClusterDetail.tsx +++ b/console/src/platform/clusters/ClusterDetail.tsx @@ -7,123 +7,44 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. -import { MenuItem, VStack } from "@chakra-ui/react"; +import { VStack } from "@chakra-ui/react"; import React from "react"; -import { Link, Route, useLocation, useParams } from "react-router-dom"; +import { Navigate, Route, useParams } from "react-router-dom"; -import { isSystemCluster } from "~/api/materialize"; -import { ClusterWithOwnership } from "~/api/materialize/cluster/clusterList"; -import DeleteObjectMenuItem from "~/components/DeleteObjectMenuItem"; -import OverflowMenu from "~/components/OverflowMenu"; +import { useUiPreview } from "~/hooks/useUiPreview"; import { Breadcrumb, - PageBreadcrumbs, PageHeader, PageTabStrip, Tab, } from "~/layouts/BaseLayout"; -import { - ClusterDetailParams, - ClusterParams, -} from "~/platform/clusters/ClusterRoutes"; +import { ClusterDetailParams } from "~/platform/clusters/ClusterRoutes"; import { SentryRoutes } from "~/sentry"; -import { useAllClusters } from "~/store/allClusters"; -import { assert } from "~/util"; -import { replaceClusterIdAndName } from "../routeHelpers"; -import AlterClusterMenuItem from "./AlterClusterMenuItem"; +import { ClusterDetailBreadcrumbs } from "./ClusterDetailBreadcrumbs"; import ClusterOverview from "./ClusterOverview"; +import { CLUSTER_OBJECT_TYPES } from "./ClusterPage/clusterObjectTypes"; +import ClusterPage from "./ClusterPage/ClusterPage"; import ClusterReplicas from "./ClusterReplicas"; import IndexList from "./IndexList"; import MaterializedViewsList from "./MaterializedViewsList"; -import { useOwners } from "./queries"; import Sinks from "./Sinks"; import Sources from "./Sources"; -import { useShowSystemObjects } from "./useShowSystemObjects"; - -const ClusterDetailBreadcrumbs = (props: { crumbs: Breadcrumb[] }) => { - const [showSystemObjects] = useShowSystemObjects(); - const { clusterId, clusterName } = useParams(); - const { data: clusters, getClusterById } = useAllClusters(); - const { isOwner } = useOwners(); - const { pathname, search } = useLocation(); - assert(clusterId); - assert(clusterName); - const cluster = getClusterById(clusterId); - - // The subscribe upserts by id, so the atom's order is arbitrary. - const clustersToShow = clusters - .filter((c) => showSystemObjects || !isSystemCluster(c.id)) - .sort((a, b) => a.name.localeCompare(b.name)); - - const menu = ( - <> - {clustersToShow.map((c) => ( - - {c.name} - - ))} - - ); - - return ( - - ) - } - /> - ); -}; -const OverflowMenuContainer = ({ - cluster, -}: { - cluster: ClusterWithOwnership; -}) => { - return ( - , - }, - { - visible: !isSystemCluster(cluster.id) && cluster?.isOwner, - render: () => - cluster && ( - undefined} - objectType="CLUSTER" - /> - ), - }, - ]} - /> +// The redesigned page nests the object lists under `objects/`. Send those links +// to the matching classic tab so they still land somewhere for opted-out users. +// A bare `objects` link opens the first type, as the redesigned page does. +const ClassicObjectsRedirect = () => { + const { objectType = CLUSTER_OBJECT_TYPES[0].path } = useParams<{ + objectType: string; + }>(); + const isKnownType = CLUSTER_OBJECT_TYPES.some( + ({ path }) => path === objectType, ); + return ; }; -const ClusterDetailPage = () => { +const ClassicClusterDetailPage = () => { const { clusterName } = useParams(); const breadcrumbs: Breadcrumb[] = React.useMemo( @@ -168,9 +89,19 @@ const ClusterDetailPage = () => { } /> } /> } /> + } /> + } + /> ); }; +const ClusterDetailPage = () => { + const { isEnabled } = useUiPreview("clusterDetailsRedesign"); + return isEnabled ? : ; +}; + export default ClusterDetailPage; diff --git a/console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx b/console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx new file mode 100644 index 0000000000000..c39bf3d9970e3 --- /dev/null +++ b/console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx @@ -0,0 +1,116 @@ +// 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 { HStack, MenuItem } from "@chakra-ui/react"; +import React from "react"; +import { Link, useLocation, useParams } from "react-router-dom"; + +import { isSystemCluster } from "~/api/materialize"; +import { ClusterWithOwnership } from "~/api/materialize/cluster/clusterList"; +import DeleteObjectMenuItem from "~/components/DeleteObjectMenuItem"; +import OverflowMenu from "~/components/OverflowMenu"; +import { UiPreviewToggle } from "~/components/UiPreviewToggle"; +import { Breadcrumb, PageBreadcrumbs } from "~/layouts/BaseLayout"; +import { ClusterParams } from "~/platform/clusters/ClusterRoutes"; +import { useAllClusters } from "~/store/allClusters"; +import { assert } from "~/util"; + +import { replaceClusterIdAndName } from "../routeHelpers"; +import AlterClusterMenuItem from "./AlterClusterMenuItem"; +import { useOwners } from "./queries"; +import { useShowSystemObjects } from "./useShowSystemObjects"; + +export const ClusterDetailBreadcrumbs = (props: { crumbs: Breadcrumb[] }) => { + const [showSystemObjects] = useShowSystemObjects(); + const { clusterId, clusterName } = useParams(); + const { data: clusters, getClusterById } = useAllClusters(); + const { isOwner } = useOwners(); + const { pathname, search } = useLocation(); + assert(clusterId); + assert(clusterName); + const cluster = getClusterById(clusterId); + + // The subscribe upserts by id, so the atom's order is arbitrary. + const clustersToShow = clusters + .filter((c) => showSystemObjects || !isSystemCluster(c.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const menu = ( + <> + {clustersToShow.map((c) => ( + + {c.name} + + ))} + + ); + + return ( + + + {cluster && ( + + )} + + } + /> + ); +}; + +const OverflowMenuContainer = ({ + cluster, +}: { + cluster: ClusterWithOwnership; +}) => { + return ( + , + }, + { + visible: !isSystemCluster(cluster.id) && cluster?.isOwner, + render: () => + cluster && ( + undefined} + objectType="CLUSTER" + /> + ), + }, + ]} + /> + ); +}; diff --git a/console/src/platform/clusters/ClusterPage/ClusterObjects.tsx b/console/src/platform/clusters/ClusterPage/ClusterObjects.tsx new file mode 100644 index 0000000000000..42bb58001338b --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ClusterObjects.tsx @@ -0,0 +1,61 @@ +// 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 { Tab, TabList, TabPanel, TabPanels, Tabs } from "@chakra-ui/react"; +import React from "react"; +import { Navigate, useNavigate, useParams } from "react-router-dom"; + +import { MAIN_CONTENT_MARGIN } from "~/layouts/BaseLayout"; + +import { CLUSTER_OBJECT_TYPES } from "./clusterObjectTypes"; + +export const ClusterObjects = () => { + const { objectType } = useParams<{ objectType: string }>(); + const navigate = useNavigate(); + const selectedIndex = CLUSTER_OBJECT_TYPES.findIndex( + ({ path }) => path === objectType, + ); + + if (selectedIndex === -1) { + return ( + + ); + } + + return ( + + navigate(`../${CLUSTER_OBJECT_TYPES[index].path}`, { + relative: "path", + }) + } + isLazy + > + + {CLUSTER_OBJECT_TYPES.map(({ path, label }) => ( + {label} + ))} + + + {CLUSTER_OBJECT_TYPES.map(({ path, Component }) => ( + + + + ))} + + + ); +}; diff --git a/console/src/platform/clusters/ClusterPage/ClusterPage.tsx b/console/src/platform/clusters/ClusterPage/ClusterPage.tsx new file mode 100644 index 0000000000000..b784e21fd2962 --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/ClusterPage.tsx @@ -0,0 +1,117 @@ +// 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 { HStack, Tag, VStack } from "@chakra-ui/react"; +import React from "react"; +import { Navigate, Route, useParams } from "react-router-dom"; + +import { Cluster } from "~/api/materialize/cluster/clusterList"; +import { + Breadcrumb, + PageHeader, + PageHeading, + PageTabStrip, + Tab, +} from "~/layouts/BaseLayout"; +import { SentryRoutes } from "~/sentry"; +import { useAllClusters } from "~/store/allClusters"; +import { assert, pluralize } from "~/util"; + +import { ClusterDetailBreadcrumbs } from "../ClusterDetailBreadcrumbs"; +import ClusterOverview from "../ClusterOverview"; +import ClusterReplicas from "../ClusterReplicas"; +import { ClusterParams } from "../ClusterRoutes"; +import { ClusterObjects } from "./ClusterObjects"; +import { CLUSTER_OBJECT_TYPES } from "./clusterObjectTypes"; + +const TABS: Tab[] = [ + { label: "Metrics", href: "..", end: true }, + { label: "Objects", href: "../objects" }, + // TODO: drop this tab once the cluster configuration drawer manages replicas. + { label: "Replicas", href: "../replicas" }, +]; + +const replicaCountLabel = (count: number) => + count === 0 + ? "No replicas" + : `${count} ${pluralize(count, "replica", "replicas")}`; + +const ClusterTitle = ({ + name, + cluster, +}: { + name: string; + cluster: Cluster | undefined; +}) => ( + + {name} + {cluster && ( + + {cluster.managed && cluster.size && {cluster.size}} + {replicaCountLabel(cluster.replicas.length)} + {!cluster.managed && Unmanaged} + + )} + +); + +const ClusterPage = () => { + const { clusterId, clusterName } = useParams(); + assert(clusterId); + assert(clusterName); + const { getClusterById } = useAllClusters(); + const cluster = getClusterById(clusterId); + + const breadcrumbs: Breadcrumb[] = React.useMemo( + () => [ + { title: "Clusters", href: "../.." }, + { title: clusterName, href: ".." }, + ], + [clusterName], + ); + + // Keying route elements on the cluster name avoids stale state when the + // breadcrumb menu switches clusters. + return ( + <> + + + + + + + + + } /> + } + /> + } + /> + } + /> + {/* The classic page's tab URLs, kept working for bookmarks and shared links. */} + {CLUSTER_OBJECT_TYPES.map(({ path }) => ( + } + /> + ))} + + + ); +}; + +export default ClusterPage; diff --git a/console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts b/console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts new file mode 100644 index 0000000000000..31b4b73cb6ffe --- /dev/null +++ b/console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts @@ -0,0 +1,25 @@ +// 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 IndexList from "../IndexList"; +import MaterializedViewsList from "../MaterializedViewsList"; +import Sinks from "../Sinks"; +import Sources from "../Sources"; + +/** Object types listed on the Objects tab, keyed by their URL segment. */ +export const CLUSTER_OBJECT_TYPES = [ + { + path: "materialized-views", + label: "Materialized views", + Component: MaterializedViewsList, + }, + { path: "indexes", label: "Indexes", Component: IndexList }, + { path: "sources", label: "Sources", Component: Sources }, + { path: "sinks", label: "Sinks", Component: Sinks }, +] as const; From f74dd3d971081722b878fa18bf6739b9746a754a Mon Sep 17 00:00:00 2001 From: Qindeel Ishtiaq Date: Fri, 2 Oct 2026 19:12:29 -0400 Subject: [PATCH 2/2] console: keep the cluster details redesign off in self-managed Self-managed deployments read flags from flexibleDeploymentFlags, where a flag that's absent counts as on, and they have no preview toggle. Without an entry, self-managed users would get the unfinished redesign with no way to opt out. Pin cluster-details-page-cns-173 to false there. Also switch the new page's relative imports to `~/` and spell the object type label "Materialized Views", matching the classic tabs. --- console/src/config/flexibleDeploymentFlags.ts | 3 + console/src/hooks/useUiPreview.test.tsx | 27 +++- .../platform/clusters/ClusterDetail.test.tsx | 79 +++++++++-- .../src/platform/clusters/ClusterDetail.tsx | 87 ++++++------ .../clusters/ClusterDetailBreadcrumbs.tsx | 2 +- .../clusters/ClusterPage/ClusterPage.tsx | 124 ++++++++---------- .../ClusterPage/clusterObjectTypes.ts | 10 +- 7 files changed, 195 insertions(+), 137 deletions(-) diff --git a/console/src/config/flexibleDeploymentFlags.ts b/console/src/config/flexibleDeploymentFlags.ts index fcd69f1c12cb4..e5bee574e7549 100644 --- a/console/src/config/flexibleDeploymentFlags.ts +++ b/console/src/config/flexibleDeploymentFlags.ts @@ -14,6 +14,9 @@ */ export const disabledFlexibleDeploymentFlags: Record = { "freshness-page-CNS164": false, + // Self-managed has no preview toggle, so a true flag would ship the + // redesign to everyone. + "cluster-details-page-cns-173": false, }; export const flexibleDeploymentFlags = new Proxy( diff --git a/console/src/hooks/useUiPreview.test.tsx b/console/src/hooks/useUiPreview.test.tsx index d25d682bf734f..f93bb74a2612e 100644 --- a/console/src/hooks/useUiPreview.test.tsx +++ b/console/src/hooks/useUiPreview.test.tsx @@ -10,7 +10,8 @@ import { screen } from "@testing-library/react"; import React from "react"; -import { UI_PREVIEWS } from "~/config/uiPreviews"; +import { flexibleDeploymentFlags } from "~/config/flexibleDeploymentFlags"; +import { UI_PREVIEWS, UiPreviewKey } from "~/config/uiPreviews"; import { useUiPreview } from "~/hooks/useUiPreview"; import { renderComponent } from "~/test/utils"; @@ -18,18 +19,24 @@ const clusterListPreview = UI_PREVIEWS.clusterListUsageMetrics; const flags: Record = {}; +const appConfig = { mode: "cloud" as "cloud" | "self-managed" }; + +// Mirrors `useFlags`: LaunchDarkly in cloud, the stubbed flags elsewhere. vi.mock("~/hooks/useFlags", () => ({ - useFlags: () => flags, + useFlags: () => + appConfig.mode === "cloud" ? flags : flexibleDeploymentFlags, })); -const appConfig = { mode: "cloud" as "cloud" | "self-managed" }; - vi.mock("~/config/useAppConfig", () => ({ useAppConfig: () => appConfig, })); -const Probe = () => { - const { isAvailable, isEnabled } = useUiPreview("clusterListUsageMetrics"); +const Probe = ({ + previewKey = "clusterListUsageMetrics", +}: { + previewKey?: UiPreviewKey; +}) => { + const { isAvailable, isEnabled } = useUiPreview(previewKey); return ( <>
available: {String(isAvailable)}
@@ -54,9 +61,15 @@ describe("useUiPreview", () => { it("ships the flagged UI outright in self-managed mode, offering no choice", async () => { appConfig.mode = "self-managed"; - flags[clusterListPreview.ldFlag] = true; await renderComponent(); expect(screen.getByText("available: false")).toBeInTheDocument(); expect(screen.getByText("enabled: true")).toBeInTheDocument(); }); + + it("keeps the cluster details redesign off in self-managed mode", async () => { + appConfig.mode = "self-managed"; + await renderComponent(); + expect(screen.getByText("available: false")).toBeInTheDocument(); + expect(screen.getByText("enabled: false")).toBeInTheDocument(); + }); }); diff --git a/console/src/platform/clusters/ClusterDetail.test.tsx b/console/src/platform/clusters/ClusterDetail.test.tsx index c82e7df764248..93a4c535e03a1 100644 --- a/console/src/platform/clusters/ClusterDetail.test.tsx +++ b/console/src/platform/clusters/ClusterDetail.test.tsx @@ -141,7 +141,7 @@ describe("ClusterDetail redesign preview", () => { }); it("shows the cluster title and the redesigned tabs", async () => { - renderClusterDetail("/u1/default"); + await renderClusterDetail("/u1/default"); expect( await screen.findByRole("heading", { level: 1, name: "default" }), @@ -157,9 +157,31 @@ describe("ClusterDetail redesign preview", () => { expect(screen.getByText("ClusterOverview component")).toBeVisible(); }); + it("tags an unmanaged cluster without replicas", async () => { + getStore().set( + allClusters, + mockSubscribeState({ + data: [ + buildClusterServerResponse( + { id: "u3", name: "adhoc", managed: false }, + [], + ), + ], + }), + ); + await renderClusterDetail("/u3/adhoc"); + + expect( + await screen.findByRole("heading", { level: 1, name: "adhoc" }), + ).toBeVisible(); + expect(screen.getByText("Unmanaged")).toBeVisible(); + expect(screen.getByText("No replicas")).toBeVisible(); + expect(screen.queryByText("small")).not.toBeInTheDocument(); + }); + it("opens the Objects tab on its first object type", async () => { const user = userEvent.setup(); - renderClusterDetail("/u1/default"); + await renderClusterDetail("/u1/default"); await user.click(await screen.findByRole("link", { name: "Objects" })); @@ -171,7 +193,7 @@ describe("ClusterDetail redesign preview", () => { it("switches object types from the type tabs", async () => { const user = userEvent.setup(); - renderClusterDetail("/u1/default/objects/materialized-views"); + await renderClusterDetail("/u1/default/objects/materialized-views"); await user.click(await screen.findByRole("tab", { name: "Indexes" })); @@ -184,7 +206,7 @@ describe("ClusterDetail redesign preview", () => { it("returns to Metrics from a nested Objects URL", async () => { const user = userEvent.setup(); - renderClusterDetail("/u1/default/objects/indexes"); + await renderClusterDetail("/u1/default/objects/indexes"); await user.click(await screen.findByRole("link", { name: "Metrics" })); @@ -195,21 +217,21 @@ describe("ClusterDetail redesign preview", () => { }); it("redirects classic tab URLs into the Objects tab", async () => { - renderClusterDetail("/u1/default/sinks"); + await renderClusterDetail("/u1/default/sinks"); await expectPathname("/u1/default/objects/sinks"); expect(await screen.findByText("Sinks component")).toBeVisible(); }); it("redirects an unknown object type to the first type", async () => { - renderClusterDetail("/u1/default/objects/tables"); + await renderClusterDetail("/u1/default/objects/tables"); await expectPathname("/u1/default/objects/materialized-views"); }); it("keeps the current tab when switching clusters", async () => { const user = userEvent.setup(); - renderClusterDetail("/u1/default/objects/indexes"); + await renderClusterDetail("/u1/default/objects/indexes"); await user.click( await screen.findByRole("button", { name: "Navigation actions" }), @@ -226,7 +248,7 @@ describe("ClusterDetail redesign preview", () => { it("switches back to the classic page", async () => { const user = userEvent.setup(); - renderClusterDetail("/u1/default"); + await renderClusterDetail("/u1/default"); await user.click( await screen.findByRole("button", { @@ -241,12 +263,30 @@ describe("ClusterDetail redesign preview", () => { screen.queryByRole("heading", { level: 1, name: "default" }), ).not.toBeInTheDocument(); }); + + it("keeps keyboard focus on the preview pill when it switches pages", async () => { + const user = userEvent.setup(); + await renderClusterDetail("/u1/default"); + + ( + await screen.findByRole("button", { + name: /Show classic cluster details page/, + }) + ).focus(); + await user.keyboard("{Enter}"); + + expect( + await screen.findByRole("button", { + name: /Show the new cluster details page/, + }), + ).toHaveFocus(); + }); }); describe("when not opted in", () => { it("offers the preview on the classic page when the flag is on", async () => { flags[redesignFlag] = true; - renderClusterDetail("/u1/default"); + await renderClusterDetail("/u1/default"); expect( await screen.findByRole("button", { @@ -256,20 +296,35 @@ describe("ClusterDetail redesign preview", () => { expect(screen.getByRole("link", { name: "Overview" })).toBeVisible(); }); + it("opens the matching redesigned tab when opting in from a classic tab URL", async () => { + flags[redesignFlag] = true; + const user = userEvent.setup(); + await renderClusterDetail("/u1/default/indexes"); + + await user.click( + await screen.findByRole("button", { + name: /Show the new cluster details page/, + }), + ); + + await expectPathname("/u1/default/objects/indexes"); + expect(await screen.findByText("IndexList component")).toBeVisible(); + }); + it("sends an unknown object type to the classic overview", async () => { - renderClusterDetail("/u1/default/objects/tables"); + await renderClusterDetail("/u1/default/objects/tables"); await expectPathname("/u1/default"); }); it("opens the first classic object tab from a redesigned Objects tab link", async () => { - renderClusterDetail("/u1/default/objects"); + await renderClusterDetail("/u1/default/objects"); await expectPathname("/u1/default/materialized-views"); }); it("redirects redesigned Objects URLs to the classic tabs", async () => { - renderClusterDetail("/u1/default/objects/sinks"); + await renderClusterDetail("/u1/default/objects/sinks"); await expectPathname("/u1/default/sinks"); expect(await screen.findByText("Sinks component")).toBeVisible(); diff --git a/console/src/platform/clusters/ClusterDetail.tsx b/console/src/platform/clusters/ClusterDetail.tsx index e17b241aded08..3a6aafaff4170 100644 --- a/console/src/platform/clusters/ClusterDetail.tsx +++ b/console/src/platform/clusters/ClusterDetail.tsx @@ -24,7 +24,7 @@ import { SentryRoutes } from "~/sentry"; import { ClusterDetailBreadcrumbs } from "./ClusterDetailBreadcrumbs"; import ClusterOverview from "./ClusterOverview"; import { CLUSTER_OBJECT_TYPES } from "./ClusterPage/clusterObjectTypes"; -import ClusterPage from "./ClusterPage/ClusterPage"; +import ClusterPage, { ClusterPageHeader } from "./ClusterPage/ClusterPage"; import ClusterReplicas from "./ClusterReplicas"; import IndexList from "./IndexList"; import MaterializedViewsList from "./MaterializedViewsList"; @@ -44,64 +44,61 @@ const ClassicObjectsRedirect = () => { return ; }; +const CLASSIC_TABS: Tab[] = [ + { label: "Overview", href: "..", end: true }, + { label: "Replicas", href: "../replicas" }, + { label: "Materialized Views", href: "../materialized-views" }, + { label: "Indexes", href: "../indexes" }, + { label: "Sources", href: "../sources" }, + { label: "Sinks", href: "../sinks" }, +]; + const ClassicClusterDetailPage = () => { const { clusterName } = useParams(); - const breadcrumbs: Breadcrumb[] = React.useMemo( - () => [ - { title: "Clusters", href: "../.." }, - { title: clusterName ?? "", href: ".." }, - ], - [clusterName], - ); - const subnavItems: Tab[] = React.useMemo( - () => [ - { label: "Overview", href: "..", end: true }, - { label: "Replicas", href: "../replicas" }, - { label: "Materialized Views", href: "../materialized-views" }, - { label: "Indexes", href: "../indexes" }, - { label: "Sources", href: "../sources" }, - { label: "Sinks", href: "../sinks" }, - ], - [], - ); - // Setting key on the route elements prevents any weird jank when you use the context // menu to switch between clusters. + return ( + + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> + + ); +}; + +const ClusterDetailPage = () => { + const { isEnabled } = useUiPreview("clusterDetailsRedesign"); + const { clusterName } = useParams(); + const breadcrumbs: Breadcrumb[] = [ + { title: "Clusters", href: "../.." }, + { title: clusterName ?? "", href: ".." }, + ]; + // Both pages share the header, so the preview pill in the breadcrumbs stays + // mounted, and keeps keyboard focus, when it switches between them. return ( <> - + {isEnabled ? ( + + ) : ( + + )} - - } /> - } - /> - } - /> - } /> - } /> - } /> - } /> - } - /> - + {isEnabled ? : } ); }; -const ClusterDetailPage = () => { - const { isEnabled } = useUiPreview("clusterDetailsRedesign"); - return isEnabled ? : ; -}; - export default ClusterDetailPage; diff --git a/console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx b/console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx index c39bf3d9970e3..b6f7f7952ea50 100644 --- a/console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx +++ b/console/src/platform/clusters/ClusterDetailBreadcrumbs.tsx @@ -18,10 +18,10 @@ import OverflowMenu from "~/components/OverflowMenu"; import { UiPreviewToggle } from "~/components/UiPreviewToggle"; import { Breadcrumb, PageBreadcrumbs } from "~/layouts/BaseLayout"; import { ClusterParams } from "~/platform/clusters/ClusterRoutes"; +import { replaceClusterIdAndName } from "~/platform/routeHelpers"; import { useAllClusters } from "~/store/allClusters"; import { assert } from "~/util"; -import { replaceClusterIdAndName } from "../routeHelpers"; import AlterClusterMenuItem from "./AlterClusterMenuItem"; import { useOwners } from "./queries"; import { useShowSystemObjects } from "./useShowSystemObjects"; diff --git a/console/src/platform/clusters/ClusterPage/ClusterPage.tsx b/console/src/platform/clusters/ClusterPage/ClusterPage.tsx index b784e21fd2962..a35cc344450b1 100644 --- a/console/src/platform/clusters/ClusterPage/ClusterPage.tsx +++ b/console/src/platform/clusters/ClusterPage/ClusterPage.tsx @@ -7,26 +7,18 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. -import { HStack, Tag, VStack } from "@chakra-ui/react"; +import { HStack, Tag } from "@chakra-ui/react"; import React from "react"; import { Navigate, Route, useParams } from "react-router-dom"; -import { Cluster } from "~/api/materialize/cluster/clusterList"; -import { - Breadcrumb, - PageHeader, - PageHeading, - PageTabStrip, - Tab, -} from "~/layouts/BaseLayout"; +import { PageHeading, PageTabStrip, Tab } from "~/layouts/BaseLayout"; +import ClusterOverview from "~/platform/clusters/ClusterOverview"; +import ClusterReplicas from "~/platform/clusters/ClusterReplicas"; +import { ClusterParams } from "~/platform/clusters/ClusterRoutes"; import { SentryRoutes } from "~/sentry"; import { useAllClusters } from "~/store/allClusters"; import { assert, pluralize } from "~/util"; -import { ClusterDetailBreadcrumbs } from "../ClusterDetailBreadcrumbs"; -import ClusterOverview from "../ClusterOverview"; -import ClusterReplicas from "../ClusterReplicas"; -import { ClusterParams } from "../ClusterRoutes"; import { ClusterObjects } from "./ClusterObjects"; import { CLUSTER_OBJECT_TYPES } from "./clusterObjectTypes"; @@ -42,75 +34,73 @@ const replicaCountLabel = (count: number) => ? "No replicas" : `${count} ${pluralize(count, "replica", "replicas")}`; +// The all-clusters subscribe updates on any cluster's replica status change. +// Looking the cluster up here keeps those updates from re-rendering the page. const ClusterTitle = ({ name, - cluster, + clusterId, }: { name: string; - cluster: Cluster | undefined; -}) => ( - - {name} - {cluster && ( - - {cluster.managed && cluster.size && {cluster.size}} - {replicaCountLabel(cluster.replicas.length)} - {!cluster.managed && Unmanaged} - - )} - -); + clusterId: string; +}) => { + const { getClusterById } = useAllClusters(); + const cluster = getClusterById(clusterId); + return ( + + {name} + {cluster && ( + + {cluster.managed && cluster.size && ( + {cluster.size} + )} + {replicaCountLabel(cluster.replicas.length)} + {!cluster.managed && Unmanaged} + + )} + + ); +}; -const ClusterPage = () => { +/** The redesigned page's part of the shared header: its title row and tabs. */ +export const ClusterPageHeader = () => { const { clusterId, clusterName } = useParams(); assert(clusterId); assert(clusterName); - const { getClusterById } = useAllClusters(); - const cluster = getClusterById(clusterId); - - const breadcrumbs: Breadcrumb[] = React.useMemo( - () => [ - { title: "Clusters", href: "../.." }, - { title: clusterName, href: ".." }, - ], - [clusterName], + return ( + <> + + + ); +}; + +const ClusterPage = () => { + const { clusterName } = useParams(); + assert(clusterName); // Keying route elements on the cluster name avoids stale state when the // breadcrumb menu switches clusters. return ( - <> - - - - - - - - - } /> + + } /> + } + /> + } + /> + } /> + {/* The classic page's tab URLs, kept working for bookmarks and shared links. */} + {CLUSTER_OBJECT_TYPES.map(({ path }) => ( } + key={path} + path={path} + element={} /> - } - /> - } - /> - {/* The classic page's tab URLs, kept working for bookmarks and shared links. */} - {CLUSTER_OBJECT_TYPES.map(({ path }) => ( - } - /> - ))} - - + ))} + ); }; diff --git a/console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts b/console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts index 31b4b73cb6ffe..2642f5e9dcb6a 100644 --- a/console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts +++ b/console/src/platform/clusters/ClusterPage/clusterObjectTypes.ts @@ -7,16 +7,16 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. -import IndexList from "../IndexList"; -import MaterializedViewsList from "../MaterializedViewsList"; -import Sinks from "../Sinks"; -import Sources from "../Sources"; +import IndexList from "~/platform/clusters/IndexList"; +import MaterializedViewsList from "~/platform/clusters/MaterializedViewsList"; +import Sinks from "~/platform/clusters/Sinks"; +import Sources from "~/platform/clusters/Sources"; /** Object types listed on the Objects tab, keyed by their URL segment. */ export const CLUSTER_OBJECT_TYPES = [ { path: "materialized-views", - label: "Materialized views", + label: "Materialized Views", Component: MaterializedViewsList, }, { path: "indexes", label: "Indexes", Component: IndexList },