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 d75510abd122..88557511332f 100644 --- a/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts +++ b/pkg/ui/workspaces/cluster-ui/src/api/databases/getDatabaseMetadataApi.ts @@ -41,7 +41,7 @@ export type DatabaseMetadataRequest = { sortBy?: string; sortOrder?: string; pagination: SimplePaginationState; - storeId?: number; + storeIds?: number[]; }; export type DatabaseMetadataResponse = @@ -64,8 +64,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.storeIds?.length) { + req.storeIds.forEach(id => urlParams.append("storeId", id.toString())); } return fetchDataJSON( @@ -74,7 +74,7 @@ export const getDatabaseMetadata = async (req: DatabaseMetadataRequest) => { }; const createKey = (req: DatabaseMetadataRequest) => { - const { name, sortBy, sortOrder, pagination, storeId } = req; + const { name, sortBy, sortOrder, pagination, storeIds } = req; return [ "databaseMetadata", name, @@ -82,7 +82,7 @@ const createKey = (req: DatabaseMetadataRequest) => { sortOrder, pagination.pageSize, pagination.pageNum, - storeId, + storeIds.map(sid => sid.toString()).join(","), ].join("|"); }; 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..5f443837cf51 --- /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("should render", () => { + 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 d8f72058fa23..7aa1b5bd98d2 100644 --- a/pkg/ui/workspaces/cluster-ui/src/types/selectTypes.ts +++ b/pkg/ui/workspaces/cluster-ui/src/types/selectTypes.ts @@ -15,3 +15,8 @@ export type ReactSelectOption = { label: string; value: T; }; + +export type GroupedReactSelectOption = { + label: string; + options: ReactSelectOption[]; +};