From 70a6478d146a355b1c0b5bde9b953e603324b21a Mon Sep 17 00:00:00 2001 From: Xin Hao Zhang Date: Wed, 18 Sep 2024 18:32:57 -0400 Subject: [PATCH] cluster-ui: create node regions selector This commit adds a select component which allows users to select nodes by region. Epic: CRDB-37558 Fixes: #131032 Release note: None --- .../api/databases/getDatabaseMetadataApi.ts | 6 +- .../nodeRegionsSelector.spec.tsx | 129 ++++++++++++++++++ .../nodeRegionsSelector.tsx | 86 ++++++++++++ .../cluster-ui/src/databasesV2/index.tsx | 34 ++--- .../cluster-ui/src/types/selectTypes.ts | 9 +- 5 files changed, 240 insertions(+), 24 deletions(-) create mode 100644 pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.spec.tsx create mode 100644 pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.tsx diff --git a/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts b/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts index 806a4c2c1878..068ac919ad33 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts @@ -43,7 +43,7 @@ export type DatabaseMetadataRequest = { sortBy?: string; sortOrder?: string; pagination: SimplePaginationState; - storeId?: number; + storeId?: number[]; }; export type DatabaseMetadataResponse = @@ -66,8 +66,8 @@ export const getDatabaseMetadata = async (req: DatabaseMetadataRequest) => { if (req.pagination.pageNum) { urlParams.append("pageNum", req.pagination.pageNum.toString()); } - if (req.storeId) { - urlParams.append("storeId", req.storeId.toString()); + if (req.storeId?.length) { + req.storeId.forEach(id => urlParams.append("storeId", id.toString())); } return fetchDataJSON( diff --git a/pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.spec.tsx b/pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.spec.tsx new file mode 100644 index 000000000000..54e5efc91dff --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.spec.tsx @@ -0,0 +1,129 @@ +// Copyright 2024 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// 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, included in the file +// licenses/APL.txt. + +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import React from "react"; + +import { useNodeStatuses } from "src/api"; +import { getRegionFromLocality } from "src/store/nodes"; +import { StoreID } from "src/types/clusterTypes"; + +import { NodeRegionsSelector } from "./nodeRegionsSelector"; + +// Mock the useNodeStatuses hook +jest.mock("src/api", () => ({ + useNodeStatuses: jest.fn(), +})); + +// Mock the getRegionFromLocality function +jest.mock("src/store/nodes", () => ({ + getRegionFromLocality: jest.fn(), +})); + +const mockNodeData = { + nodes: [ + { + desc: { node_id: 1, locality: { region: "us-east" } }, + store_statuses: [ + { desc: { store_id: 101 } }, + { desc: { store_id: 102 } }, + ], + }, + { + desc: { node_id: 2, locality: { region: "us-west" } }, + store_statuses: [{ desc: { store_id: 201 } }], + }, + { + desc: { node_id: 3, locality: { region: "us-east" } }, + store_statuses: [{ desc: { store_id: 301 } }], + }, + ], +}; + +describe("NodeRegionsSelector", () => { + beforeEach(() => { + (useNodeStatuses as jest.Mock).mockReturnValue({ + isLoading: false, + data: mockNodeData, + }); + (getRegionFromLocality as jest.Mock).mockImplementation( + locality => locality.region, + ); + }); + + it("renders without crashing", () => { + render( {}} />); + expect(screen.getByText("Nodes")).toBeTruthy(); + }); + + it("displays correct options based on node data", async () => { + render( {}} />); + + const select = screen.getByText("Nodes"); + fireEvent.keyDown(select, { key: "ArrowDown" }); + + await waitFor(() => { + expect(screen.getByText("us-east")).toBeTruthy(); + expect(screen.getByText("us-west")).toBeTruthy(); + expect(screen.getByText("n1")).toBeTruthy(); + expect(screen.getByText("n2")).toBeTruthy(); + expect(screen.getByText("n3")).toBeTruthy(); + }); + }); + + it("calls onChange with correct values when selecting options", async () => { + const value: StoreID[] = []; + const mockOnChange = jest.fn((selected: StoreID[]) => { + value.push(...selected); + }); + render(); + + const select = screen.getByText("Nodes"); + fireEvent.keyDown(select, { key: "ArrowDown" }); + + await waitFor(() => { + fireEvent.click(screen.getByText("n1")); + }); + + expect(mockOnChange).toHaveBeenCalledWith([101, 102]); + }); + + it("displays selected values correctly", () => { + render( + {}} + />, + ); + + expect(screen.getByText("n1")).toBeTruthy(); + expect(screen.getByText("n2")).toBeTruthy(); + }); + + it("handles loading state", () => { + (useNodeStatuses as jest.Mock).mockReturnValue({ + isLoading: true, + data: mockNodeData, + }); + + render( {}} />); + + const select = screen.getByText("Nodes"); + fireEvent.keyDown(select, { key: "ArrowDown" }); + + // In the loading state, the component should still render options + // based on the existing data + expect(screen.getByText("us-east")).toBeTruthy(); + expect(screen.getByText("us-west")).toBeTruthy(); + expect(screen.getByText("n1")).toBeTruthy(); + expect(screen.getByText("n2")).toBeTruthy(); + expect(screen.getByText("n3")).toBeTruthy(); + }); +}); diff --git a/pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.tsx b/pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.tsx new file mode 100644 index 000000000000..10d41d46beb2 --- /dev/null +++ b/pkg/ui/workspaces/cluster-ui/src/components/nodeRegionsSelector/nodeRegionsSelector.tsx @@ -0,0 +1,86 @@ +// Copyright 2024 The Cockroach Authors. +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// 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, included in the file +// licenses/APL.txt. + +import React, { useMemo } from "react"; +import Select, { OptionsType } from "react-select"; + +import { useNodeStatuses } from "src/api"; +import { getRegionFromLocality } from "src/store/nodes"; +import { NodeID, StoreID } from "src/types/clusterTypes"; +import { + GroupedReactSelectOption, + ReactSelectOption, +} from "src/types/selectTypes"; + +type NodeRegionsSelectorProps = { + value: StoreID[]; + onChange: (selected: StoreID[]) => void; +}; + +export const NodeRegionsSelector: React.FC = ({ + value, + onChange, +}) => { + const nodesResp = useNodeStatuses(); + + const nodeOptions: GroupedReactSelectOption[] = useMemo(() => { + const optionsMap: Record = {}; + if (nodesResp.isLoading && !nodesResp.data?.nodes) { + return []; + } + + nodesResp.data.nodes.forEach(node => { + const region = getRegionFromLocality(node.desc.locality); + if (optionsMap[region] == null) { + optionsMap[region] = []; + } + optionsMap[region].push({ + nid: node.desc.node_id as NodeID, + sids: node.store_statuses.map(s => s.desc.store_id as StoreID), + }); + }); + + return Object.entries(optionsMap).map(([region, nodes]) => { + return { + label: region, + options: nodes.map(n => ({ + label: `n${n.nid}`, + value: n.sids, + })), + }; + }); + }, [nodesResp]); + + const onSelectChange = ( + selected: OptionsType>, + ) => { + onChange(selected.map(s => s.value).reduce((acc, v) => acc.concat(v), [])); + }; + + const selectValue: OptionsType> = + nodeOptions.reduce((acc, region) => { + const nodes = region.options.filter(n => + value.some(v => n.value.includes(v)), + ); + return [...acc, ...nodes]; + }, []); + + return ( + diff --git a/pkg/ui/workspaces/cluster-ui/src/types/selectTypes.ts b/pkg/ui/workspaces/cluster-ui/src/types/selectTypes.ts index 52b7751d70ef..7aa1b5bd98d2 100644 --- a/pkg/ui/workspaces/cluster-ui/src/types/selectTypes.ts +++ b/pkg/ui/workspaces/cluster-ui/src/types/selectTypes.ts @@ -11,7 +11,12 @@ // This is temporary. We'll remove this when we can access the shared // component library from the console. In the meantime this just removes // some type pollution from new components using react-select. -export type ReactSelectOption = { +export type ReactSelectOption = { label: string; - value: string; + value: T; +}; + +export type GroupedReactSelectOption = { + label: string; + options: ReactSelectOption[]; };