diff --git a/cypress/e2e/with-users/subnets/subnets.spec.ts b/cypress/e2e/with-users/subnets/subnets.spec.ts index 2da29e5dad..da5963d670 100644 --- a/cypress/e2e/with-users/subnets/subnets.spec.ts +++ b/cypress/e2e/with-users/subnets/subnets.spec.ts @@ -13,7 +13,6 @@ context("Subnets", () => { it("displays the main networking view correctly", () => { const expectedHeaders = [ - "Fabric", "VLAN", "DHCP", "Subnet", @@ -21,7 +20,7 @@ context("Subnets", () => { "Space", ]; - cy.findByRole("table", { name: "Subnets by Fabric" }).within(() => { + cy.findByRole("grid", { name: "Subnets by Fabric" }).within(() => { expectedHeaders.forEach((name) => { cy.findByRole("columnheader", { name }).should("exist"); }); @@ -31,35 +30,33 @@ context("Subnets", () => { it("updates the URL to default grouping if no group paramater has been set", () => { cy.visit(generateMAASURL("/networks")); - cy.findByRole("tab", { name: /fabric/i }).should( - "have.attr", - "aria-selected", - "true" + cy.findByRole("combobox", { name: /group by/i }).should( + "have.value", + "fabric" ); cy.url().should("include", generateMAASURL("/networks?by=fabric")); }); it("allows grouping by fabric and space", () => { - cy.findByRole("table", { name: "Subnets by Fabric" }).within(() => { - cy.findAllByRole("columnheader").first().should("have.text", "Fabric"); + cy.findByRole("grid", { name: "Subnets by Fabric" }).within(() => { + cy.findAllByRole("columnheader").first().should("have.text", "VLAN"); }); - cy.findByRole("tab", { name: /fabric/i }).should( - "have.attr", - "aria-selected", - "true" + cy.findByRole("combobox", { name: /group by/i }).should( + "have.value", + "fabric" ); - cy.findByRole("tab", { name: /space/i }).should( - "have.attr", - "aria-selected", - "false" + + cy.findByRole("combobox", { name: /group by/i }).should( + "not.have.value", + "space" ); - cy.findByRole("tab", { name: /space/i }).click(); + cy.findByRole("combobox", { name: /group by/i }).select("space"); - cy.findByRole("table", { name: "Subnets by Space" }).within(() => { - cy.findAllByRole("columnheader").first().should("have.text", "Space"); + cy.findByRole("grid", { name: "Subnets by Space" }).within(() => { + cy.findAllByRole("columnheader").first().should("have.text", "VLAN"); }); cy.url().should("include", generateMAASURL("/networks?by=space")); diff --git a/src/app/base/components/GroupRow/GroupRow.stories.tsx b/src/app/base/components/GroupRow/GroupRow.stories.tsx new file mode 100644 index 0000000000..99e76e2eba --- /dev/null +++ b/src/app/base/components/GroupRow/GroupRow.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta } from "@storybook/react"; + +import GroupRow from "./GroupRow"; + +const meta: Meta = { + title: "Components/GroupRow", + component: GroupRow, + tags: ["autodocs"], +}; + +export default meta; + +export const Example = { + args: { + itemName: "network", + groupName: "fabric", + count: 2, + }, +}; diff --git a/src/app/base/components/GroupRow/GroupRow.tsx b/src/app/base/components/GroupRow/GroupRow.tsx new file mode 100644 index 0000000000..06f0286617 --- /dev/null +++ b/src/app/base/components/GroupRow/GroupRow.tsx @@ -0,0 +1,27 @@ +import pluralize from "pluralize"; + +import DoubleRow from "app/base/components/DoubleRow"; + +export enum Label { + HideGroup = "Hide", + ShowGroup = "Show", +} + +type GroupRowProps = { + itemName: string; + groupName: string; + count: number; +}; + +const GroupRow = ({ itemName, groupName, count }: GroupRowProps) => { + return ( + <> + {groupName}} + secondary={{pluralize(itemName, count, true)}} + /> + + ); +}; + +export default GroupRow; diff --git a/src/app/base/components/GroupRow/index.ts b/src/app/base/components/GroupRow/index.ts new file mode 100644 index 0000000000..abaae98cfa --- /dev/null +++ b/src/app/base/components/GroupRow/index.ts @@ -0,0 +1 @@ +export { default } from "./GroupRow"; diff --git a/src/app/subnets/views/SubnetsList/SubnetsList.test.tsx b/src/app/subnets/views/SubnetsList/SubnetsList.test.tsx index 57bb8ee2fc..040d75c4f3 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsList.test.tsx +++ b/src/app/subnets/views/SubnetsList/SubnetsList.test.tsx @@ -36,7 +36,7 @@ it("displays loading text", async () => { route: urls.index, }); - expect(screen.getAllByRole("table")).toHaveLength(1); + expect(screen.getAllByRole("grid")).toHaveLength(1); await userEvent.type(screen.getByRole("searchbox"), "non-existent-fabric"); await waitFor(() => expect(screen.getByText(/Loading.../)).toBeInTheDocument() @@ -50,15 +50,15 @@ it("displays correct text when there are no results for the search criteria", as route: urls.index, }); - expect(screen.getAllByRole("table")).toHaveLength(1); - const tableBody = screen.getAllByRole("rowgroup")[1]; + expect(screen.getAllByRole("grid")).toHaveLength(1); await userEvent.type(screen.getByRole("searchbox"), "non-existent-fabric"); await waitFor(() => - expect(within(tableBody).getByText(/No results/)).toBeInTheDocument() + expect( + within(screen.getByRole("grid")).getByText(/No results/) + ).toBeInTheDocument() ); - expect(within(tableBody).getAllByRole("row")).toHaveLength(1); }); it("sets the options from the URL on load", async () => { diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/FabricTable/FabricTable.tsx b/src/app/subnets/views/SubnetsList/SubnetsTable/FabricTable/FabricTable.tsx index a8e2855dab..29de7f104a 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/FabricTable/FabricTable.tsx +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/FabricTable/FabricTable.tsx @@ -1,15 +1,20 @@ import { useMemo } from "react"; -import { Pagination, ModularTable } from "@canonical/react-components"; +import { Pagination, MainTable } from "@canonical/react-components"; -import { CellContents } from "app/subnets/views/SubnetsList/SubnetsTable/components"; +import TableHeader from "app/base/components/TableHeader"; +import { generateSubnetGroupRows } from "app/subnets/views/SubnetsList/SubnetsTable/components"; import { + fabricTableColumns, subnetColumnLabels, SubnetsColumns, } from "app/subnets/views/SubnetsList/SubnetsTable/constants"; import { usePagination } from "app/subnets/views/SubnetsList/SubnetsTable/hooks"; import type { SubnetsTableRow } from "app/subnets/views/SubnetsList/SubnetsTable/types"; -import { groupRowsByFabricAndVlan } from "app/subnets/views/SubnetsList/SubnetsTable/utils"; +import { + groupSubnetData, + groupRowsByFabric, +} from "app/subnets/views/SubnetsList/SubnetsTable/utils"; const FabricTable = ({ data, @@ -19,62 +24,73 @@ const FabricTable = ({ emptyMsg: string; }): JSX.Element => { const { pageData, ...paginationProps } = usePagination(data); + const headers = useMemo( + () => [ + { + "aria-label": subnetColumnLabels[SubnetsColumns.FABRIC], + key: SubnetsColumns.FABRIC, + content: ( + {subnetColumnLabels[SubnetsColumns.FABRIC]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.VLAN], + key: SubnetsColumns.VLAN, + content: ( + {subnetColumnLabels[SubnetsColumns.VLAN]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.DHCP], + key: SubnetsColumns.DHCP, + content: ( + {subnetColumnLabels[SubnetsColumns.DHCP]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.SUBNET], + key: SubnetsColumns.SUBNET, + content: ( + {subnetColumnLabels[SubnetsColumns.SUBNET]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.IPS], + key: SubnetsColumns.IPS, + content: ( + {subnetColumnLabels[SubnetsColumns.IPS]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.SPACE], + key: SubnetsColumns.SPACE, + className: "u-align--right", + content: ( + {subnetColumnLabels[SubnetsColumns.SPACE]} + ), + }, + ], + [] + ); + + const groupedData = useMemo(() => groupSubnetData(data, "fabric"), [data]); + const rowData = useMemo(() => groupRowsByFabric(pageData), [pageData]); + + const rows = generateSubnetGroupRows({ + groups: rowData, + itemName: "network", + columnLength: fabricTableColumns.length, + groupMap: groupedData, + }); return ( <> - [ - { - Header: subnetColumnLabels[SubnetsColumns.FABRIC], - accessor: SubnetsColumns.FABRIC, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.VLAN], - accessor: SubnetsColumns.VLAN, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.DHCP], - accessor: SubnetsColumns.DHCP, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.SUBNET], - accessor: SubnetsColumns.SUBNET, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.IPS], - accessor: SubnetsColumns.IPS, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.SPACE], - accessor: SubnetsColumns.SPACE, - className: "u-align--right", - Cell: CellContents, - }, - ], - [] - )} - data={groupRowsByFabricAndVlan(pageData)} - emptyMsg={emptyMsg} - getCellProps={({ value, column }) => ({ - className: `subnets-table__cell--${column.id}${ - value.isVisuallyHidden ? " u-no-border--top" : "" - }`, - role: column.id === "fabric" ? "rowheader" : undefined, - })} - getHeaderProps={(header) => ({ - className: `subnets-table__cell--${header.id}`, - })} - getRowProps={(row) => ({ - "aria-label": row.values.fabric.label, - })} + className="fabric-table" + emptyStateMsg={emptyMsg} + headers={headers} + rows={rows} /> diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/SpaceTable/SpaceTable.tsx b/src/app/subnets/views/SubnetsList/SubnetsTable/SpaceTable/SpaceTable.tsx index ea7e84d371..09f42d2c06 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/SpaceTable/SpaceTable.tsx +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/SpaceTable/SpaceTable.tsx @@ -1,18 +1,20 @@ import { useMemo } from "react"; -import { Pagination, ModularTable } from "@canonical/react-components"; +import { Pagination, MainTable } from "@canonical/react-components"; +import TableHeader from "app/base/components/TableHeader"; +import { generateSubnetGroupRows } from "app/subnets/views/SubnetsList/SubnetsTable/components"; import { - CellContents, - SpaceCellContents, -} from "app/subnets/views/SubnetsList/SubnetsTable/components"; -import { + spaceTableColumns, subnetColumnLabels, SubnetsColumns, } from "app/subnets/views/SubnetsList/SubnetsTable/constants"; import { usePagination } from "app/subnets/views/SubnetsList/SubnetsTable/hooks"; import type { SubnetsTableRow } from "app/subnets/views/SubnetsList/SubnetsTable/types"; -import { groupRowsBySpace } from "app/subnets/views/SubnetsList/SubnetsTable/utils"; +import { + groupRowsBySpace, + groupSubnetData, +} from "app/subnets/views/SubnetsList/SubnetsTable/utils"; const SpaceTable = ({ data, @@ -22,62 +24,73 @@ const SpaceTable = ({ emptyMsg: string; }): JSX.Element => { const { pageData, ...paginationProps } = usePagination(data); + const headers = useMemo( + () => [ + { + "aria-label": subnetColumnLabels[SubnetsColumns.FABRIC], + key: SubnetsColumns.FABRIC, + content: ( + {subnetColumnLabels[SubnetsColumns.FABRIC]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.VLAN], + key: SubnetsColumns.VLAN, + content: ( + {subnetColumnLabels[SubnetsColumns.VLAN]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.DHCP], + key: SubnetsColumns.DHCP, + content: ( + {subnetColumnLabels[SubnetsColumns.DHCP]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.SUBNET], + key: SubnetsColumns.SUBNET, + content: ( + {subnetColumnLabels[SubnetsColumns.SUBNET]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.IPS], + key: SubnetsColumns.IPS, + content: ( + {subnetColumnLabels[SubnetsColumns.IPS]} + ), + }, + { + "aria-label": subnetColumnLabels[SubnetsColumns.SPACE], + className: "u-align--right", + key: SubnetsColumns.SPACE, + content: ( + {subnetColumnLabels[SubnetsColumns.SPACE]} + ), + }, + ], + [] + ); + + const groupedData = useMemo(() => groupSubnetData(data, "space"), [data]); + const rowData = useMemo(() => groupRowsBySpace(pageData), [pageData]); + + const rows = generateSubnetGroupRows({ + groups: rowData, + itemName: "network", + columnLength: spaceTableColumns.length, + groupMap: groupedData, + }); return ( <> - [ - { - Header: subnetColumnLabels[SubnetsColumns.SPACE], - accessor: SubnetsColumns.SPACE, - Cell: SpaceCellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.VLAN], - accessor: SubnetsColumns.VLAN, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.DHCP], - accessor: SubnetsColumns.DHCP, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.FABRIC], - accessor: SubnetsColumns.FABRIC, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.SUBNET], - accessor: SubnetsColumns.SUBNET, - Cell: CellContents, - }, - { - Header: subnetColumnLabels[SubnetsColumns.IPS], - accessor: SubnetsColumns.IPS, - className: "u-align--right", - Cell: CellContents, - }, - ], - [] - )} - data={groupRowsBySpace(pageData)} - emptyMsg={emptyMsg} - getCellProps={({ value, column }) => ({ - className: `subnets-table__cell--${column.id}${ - value.isVisuallyHidden ? " u-no-border--top" : "" - }`, - role: column.id === "space" ? "rowheader" : undefined, - })} - getHeaderProps={(header) => ({ - className: `subnets-table__cell--${header.id}`, - })} - getRowProps={(row) => ({ - "aria-label": row.values.space.label, - })} + className="space-table" + emptyStateMsg={emptyMsg} + headers={headers} + rows={rows} /> diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/SubnetsTable.test.tsx b/src/app/subnets/views/SubnetsList/SubnetsTable/SubnetsTable.test.tsx index d52fe40790..9dd9ac1c7b 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/SubnetsTable.test.tsx +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/SubnetsTable.test.tsx @@ -15,7 +15,14 @@ import { spaceState as spaceStateFactory, rootState as rootStateFactory, } from "testing/factories"; -import { userEvent, render, screen, within, waitFor } from "testing/utils"; +import { + userEvent, + render, + screen, + within, + waitFor, + renderWithBrowserRouter, +} from "testing/utils"; const getMockState = ({ numberOfFabrics } = { numberOfFabrics: 50 }) => { const fabrics = [ @@ -55,7 +62,7 @@ it("renders a single table variant at a time", () => { ); - expect(screen.getAllByRole("table")).toHaveLength(1); + expect(screen.getAllByRole("grid")).toHaveLength(1); }); it("renders Subnets by Fabric table when grouping by Fabric", () => { @@ -77,7 +84,7 @@ it("renders Subnets by Fabric table when grouping by Fabric", () => { ); expect( - screen.getByRole("table", { name: "Subnets by Fabric" }) + screen.getByRole("grid", { name: "Subnets by Fabric" }) ).toBeInTheDocument(); }); @@ -100,7 +107,7 @@ it("renders Subnets by Space table when grouping by Space", () => { ); expect( - screen.getByRole("table", { name: "Subnets by Space" }) + screen.getByRole("grid", { name: "Subnets by Space" }) ).toBeInTheDocument(); }); @@ -123,7 +130,7 @@ it("displays a correct number of pages", () => { ); expect( - screen.getByRole("table", { name: "Subnets by Fabric" }) + screen.getByRole("grid", { name: "Subnets by Fabric" }) ).toBeInTheDocument(); const numberOfPages = @@ -163,18 +170,8 @@ it("updates the list of items correctly when navigating to another page", async const tableBody = screen.getAllByRole("rowgroup")[1]; expect( - within(tableBody).getByRole("link", { - name: "fabric-1", - }) - ).toBeInTheDocument(); - expect( - within(tableBody).getByRole("link", { - name: "fabric-25", - }) + within(tableBody).getByRole("link", { name: "fabric-1" }) ).toBeInTheDocument(); - await waitFor(() => - expect(within(tableBody).getAllByRole("row")).toHaveLength(25) - ); await userEvent.click( within(screen.getByRole("navigation")).getByRole("button", { @@ -182,19 +179,17 @@ it("updates the list of items correctly when navigating to another page", async }) ); await waitFor(() => - expect(within(tableBody).getAllByRole("row")).toHaveLength(25) + expect( + within(tableBody).getAllByRole("link", { name: /fabric/i }) + ).toHaveLength(25) ); await waitFor(() => expect( - within(tableBody).getByRole("link", { - name: "fabric-26", - }) + within(tableBody).getByRole("link", { name: "fabric-26" }) ).toBeInTheDocument() ); expect( - within(tableBody).getByRole("link", { - name: "fabric-50", - }) + within(tableBody).getByRole("link", { name: "fabric-50" }) ).toBeInTheDocument(); }); @@ -232,14 +227,6 @@ it("displays correctly paginated rows", async () => { }); const mockStore = configureStore(); const store = mockStore(state); - const firstPageFabrics = state.fabric.items.slice( - 0, - SUBNETS_TABLE_ITEMS_PER_PAGE - ); - const secondPageFabrics = state.fabric.items.slice( - SUBNETS_TABLE_ITEMS_PER_PAGE, - SUBNETS_TABLE_ITEMS_PER_PAGE * 2 - ); render( @@ -256,18 +243,12 @@ it("displays correctly paginated rows", async () => { ); const tableBody = screen.getAllByRole("rowgroup")[1]; + // Get grouped rows + const groupRows = screen.getAllByRole("row", { name: /group/i }); expect(within(tableBody).getAllByRole("row")).toHaveLength( - SUBNETS_TABLE_ITEMS_PER_PAGE + SUBNETS_TABLE_ITEMS_PER_PAGE + groupRows.length ); - within(tableBody) - .getAllByRole("row") - .forEach((row, index) => { - expect(row.textContent).toEqual( - expect.stringContaining(firstPageFabrics[index].name) - ); - }); - await userEvent.click( screen.getByRole("button", { name: "Next page", @@ -283,17 +264,9 @@ it("displays correctly paginated rows", async () => { ).toHaveTextContent("2") ); - expect(within(tableBody).getAllByRole("row")).toHaveLength( - SUBNETS_TABLE_ITEMS_PER_PAGE - ); - - within(tableBody) - .getAllByRole("row") - .forEach((row, index) => { - expect(row.textContent).toEqual( - expect.stringContaining(secondPageFabrics[index].name) - ); - }); + expect( + within(tableBody).getAllByRole("link", { name: /fabric/i }) + ).toHaveLength(SUBNETS_TABLE_ITEMS_PER_PAGE); }); it("displays the last available page once the currently active has no items", async () => { @@ -329,12 +302,10 @@ it("displays the last available page once the currently active has no items", as ); await waitFor(() => - expect(within(tableBody).getAllByRole("row")).toHaveLength(1) + expect(within(tableBody).getAllByRole("row")).toHaveLength(2) ); expect( - within(tableBody).getByRole("link", { - name: `fabric-${numberOfFabrics}`, - }) + within(tableBody).getByRole("link", { name: `fabric-${numberOfFabrics}` }) ).toBeInTheDocument(); const updatedState = getMockState({ @@ -356,17 +327,14 @@ it("displays the last available page once the currently active has no items", as ); + const pagination = screen.getByRole("navigation", { name: "pagination" }); await waitFor(() => expect( - screen - .getByRole("navigation", { name: "pagination" }) - // eslint-disable-next-line testing-library/no-node-access - .querySelector(".is-active") - ).toHaveTextContent("2") - ); - expect(within(tableBody).getAllByRole("row")).toHaveLength( - SUBNETS_TABLE_ITEMS_PER_PAGE + within(pagination).getByRole("button", { name: "2" }) + ).toHaveAttribute("aria-current", "page") ); + + expect(within(tableBody).getAllByRole("row")).toHaveLength(2); }); it("remains on the same page once the data is updated and page is still available", async () => { @@ -400,11 +368,8 @@ it("remains on the same page once the data is updated and page is still availabl await waitFor(() => expect( - screen - .getByRole("navigation") - // eslint-disable-next-line testing-library/no-node-access - .querySelector(".is-active") - ).toHaveTextContent("2") + within(pagination).getByRole("button", { name: "2" }) + ).toHaveAttribute("aria-current", "page") ); const updatedState = getMockState({ @@ -428,10 +393,40 @@ it("remains on the same page once the data is updated and page is still availabl await waitFor(() => expect( - screen - .getByRole("navigation") - // eslint-disable-next-line testing-library/no-node-access - .querySelector(".is-active") - ).toHaveTextContent("2") + within(pagination).getByRole("button", { name: "2" }) + ).toHaveAttribute("aria-current", "page") + ); +}); + +it("displays the table group summary at the top of every page", async () => { + const numberOfFabrics = SUBNETS_TABLE_ITEMS_PER_PAGE * 2; + const state = getMockState({ + numberOfFabrics, + }); + + renderWithBrowserRouter( + , + { route: urls.index, state } + ); + + const tableBody = screen.getAllByRole("rowgroup")[1]; + expect(within(tableBody).getAllByRole("row")[0]).toHaveTextContent("network"); + + const pagination = screen.getByRole("navigation", { name: "pagination" }); + await userEvent.click( + within(pagination).getByRole("button", { + name: "2", + }) + ); + + await waitFor(() => + expect( + within(pagination).getByRole("button", { name: "2" }) + ).toHaveAttribute("aria-current", "page") + ); + + const tableBody2 = screen.getAllByRole("rowgroup")[1]; + expect(within(tableBody2).getAllByRole("row")[0]).toHaveTextContent( + "network" ); }); diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/components.tsx b/src/app/subnets/views/SubnetsList/SubnetsTable/components.tsx index 7cb671d838..7f376efc6d 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/components.tsx +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/components.tsx @@ -2,9 +2,19 @@ import type { PropsWithChildren } from "react"; import { useState } from "react"; import { Button } from "@canonical/react-components"; +import classNames from "classnames"; import { Link } from "react-router-dom-v5-compat"; -import type { SubnetsTableColumn } from "./types"; +import { SubnetsColumns } from "./constants"; +import type { + FabricRowContent, + FabricTableRow, + SpaceTableRow, + SubnetsTableColumn, + SubnetsTableRow, +} from "./types"; + +import GroupRow from "app/base/components/GroupRow"; export const SpaceCellContents = ({ value, @@ -59,3 +69,123 @@ export const CellContents = ({ ); + +const generateSubnetRow = ({ + content, + classes, + key, +}: { + content: FabricRowContent; + key: string | number; + classes?: string; +}) => { + const columns = [ + { + "aria-label": SubnetsColumns.FABRIC, + key: SubnetsColumns.FABRIC, + content: content[SubnetsColumns.FABRIC], + }, + { + "aria-label": SubnetsColumns.VLAN, + key: SubnetsColumns.VLAN, + content: content[SubnetsColumns.VLAN], + }, + { + "aria-label": SubnetsColumns.DHCP, + key: SubnetsColumns.DHCP, + content: content[SubnetsColumns.DHCP], + }, + { + "aria-label": SubnetsColumns.SUBNET, + key: SubnetsColumns.SUBNET, + content: content[SubnetsColumns.SUBNET], + }, + { + "aria-label": SubnetsColumns.IPS, + key: SubnetsColumns.IPS, + content: content[SubnetsColumns.IPS], + }, + { + "aria-label": SubnetsColumns.SPACE, + key: SubnetsColumns.SPACE, + content: content[SubnetsColumns.SPACE], + className: "u-align--right", + }, + ]; + + return { + key, + className: classNames(classes), + columns, + }; +}; + +export const generateSubnetRows = (subnets: SubnetsTableRow[]) => { + return subnets.map((subnet, index) => { + const content = { + [SubnetsColumns.FABRIC]: ( + + ), + [SubnetsColumns.VLAN]: ( + + ), + [SubnetsColumns.DHCP]: ( + + ), + [SubnetsColumns.SUBNET]: ( + + ), + [SubnetsColumns.IPS]: , + [SubnetsColumns.SPACE]: ( + + ), + }; + return generateSubnetRow({ + key: `${subnet.sortData.vlanId}-${subnet.sortData.fabricId}-${index}`, + content, + classes: "subnet-row truncated-border", + }); + }); +}; + +export const generateSubnetGroupRows = ({ + itemName, + groups, + columnLength, + groupMap, +}: { + itemName: string; + groups: FabricTableRow[] | SpaceTableRow[]; + columnLength: number; + groupMap: Record< + string | number, + { + count: number; + } + >; +}) => { + const generateGroupRow = (name: string) => { + return { + "aria-label": `${name} group`, + className: "", + columns: [ + { + colSpan: columnLength, + content: ( + + ), + }, + ], + }; + }; + + return groups.flatMap((group) => { + const { networks } = group; + const name = "fabricName" in group ? group.fabricName : group.spaceName; + return [generateGroupRow(name as string), ...generateSubnetRows(networks)]; + }); +}; diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/constants.ts b/src/app/subnets/views/SubnetsList/SubnetsTable/constants.ts index 32692b398b..2db847ac39 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/constants.ts +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/constants.ts @@ -7,6 +7,22 @@ export enum SubnetsColumns { SPACE = "space", } +export const fabricTableColumns = [ + "vlan", + "dhcp", + "subnet", + "ips", + "space", +] as const; + +export const spaceTableColumns = [ + "vlan", + "dhcp", + "fabric", + "subnet", + "ips", +] as const; + export const subnetColumnLabels = { [SubnetsColumns.FABRIC]: "Fabric", [SubnetsColumns.VLAN]: "VLAN", diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/types.ts b/src/app/subnets/views/SubnetsList/SubnetsTable/types.ts index 7c5eb01878..4d22e8dd00 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/types.ts +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/types.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + import type { SubnetsColumns } from "./constants"; import type { Fabric } from "app/store/fabric/types"; @@ -30,6 +32,19 @@ export type SortData = { cidr: SortKey; }; +export type FabricTableRow = { + fabricId: SortKey; + fabricName: SortKey; + isCollapsed: boolean; + networks: SubnetsTableRow[]; +}; + +export type SpaceTableRow = { + spaceName: SortKey; + isCollapsed: boolean; + networks: SubnetsTableRow[]; +}; + export type SubnetGroupByProps = { groupBy: GroupByKey; setGroupBy: (group: GroupByKey) => void; @@ -45,3 +60,12 @@ export type SortDataKey = export type SubnetsTableRow = Record & { sortData: SortData; }; + +export type FabricRowContent = { + [SubnetsColumns.FABRIC]: ReactNode; + [SubnetsColumns.VLAN]: ReactNode; + [SubnetsColumns.DHCP]: ReactNode; + [SubnetsColumns.SUBNET]: ReactNode; + [SubnetsColumns.IPS]: ReactNode; + [SubnetsColumns.SPACE]: ReactNode; +}; diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/utils.test.tsx b/src/app/subnets/views/SubnetsList/SubnetsTable/utils.test.tsx index 7d860e5541..f6f5bc3fd7 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/utils.test.tsx +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/utils.test.tsx @@ -1,8 +1,9 @@ import { filterSubnetsBySearchText, getTableData, - groupRowsByFabricAndVlan, + groupRowsByFabric, groupRowsBySpace, + groupSubnetData, } from "./utils"; import { @@ -62,35 +63,6 @@ test("getTableData returns spaces sorted in a correct order", () => { }); }); -test("groupRowsByFabricAndVlan returns grouped fabrics in a correct format", () => { - const fabrics = [ - fabricFactory({ id: 1, vlan_ids: [1] }), - fabricFactory({ id: 2 }), - ]; - const vlans = [vlanFactory({ fabric: 1 }), vlanFactory({ fabric: 1 })]; - const subnets = [subnetFactory(), subnetFactory()]; - const spaces = [spaceFactory()]; - const tableData = getTableData({ fabrics, vlans, subnets, spaces }, "fabric"); - - expect(groupRowsByFabricAndVlan(tableData)[0].fabric).toStrictEqual({ - href: "/fabric/1", - isVisuallyHidden: false, - label: fabrics[0].name, - }); - - expect(groupRowsByFabricAndVlan(tableData)[1].fabric).toStrictEqual({ - href: "/fabric/1", - isVisuallyHidden: true, - label: fabrics[0].name, - }); - - expect(groupRowsByFabricAndVlan(tableData)[2].fabric).toStrictEqual({ - href: "/fabric/2", - isVisuallyHidden: false, - label: fabrics[1].name, - }); -}); - test("groupRowsBySpace returns grouped spaces in a correct format", () => { const fabrics = [fabricFactory({ id: 1, vlan_ids: [1, 2, 3] })]; const vlans = [ @@ -105,23 +77,10 @@ test("groupRowsBySpace returns grouped spaces in a correct format", () => { ]; const tableData = getTableData({ fabrics, vlans, subnets, spaces }, "fabric"); - expect(groupRowsBySpace(tableData)[0].space).toStrictEqual({ - href: "/space/1", - isVisuallyHidden: false, - label: "space-1", - }); - - expect(groupRowsBySpace(tableData)[1].space).toStrictEqual({ - href: "/space/1", - isVisuallyHidden: true, - label: "space-1", - }); - - expect(groupRowsBySpace(tableData)[2].space).toStrictEqual({ - href: "/space/2", - isVisuallyHidden: false, - label: "space-2", - }); + expect(groupRowsBySpace(tableData)[0].spaceName).toBe(spaces[0].name); + expect(groupRowsBySpace(tableData)[0].networks[0]).toStrictEqual( + tableData[0] + ); }); test("filterSubnetsBySearchText matches a correct number of results with each value", () => { @@ -148,3 +107,38 @@ test("filterSubnetsBySearchText matches a correct number of results with each va expect(filterSubnetsBySearchText(tableRows, "test-vlan")).toHaveLength(1); expect(filterSubnetsBySearchText(tableRows, "172.16.1.0")).toHaveLength(1); }); + +test("groupRowsByFabric returns grouped rows in a correct format", () => { + const fabrics = [ + fabricFactory({ id: 1, vlan_ids: [1] }), + fabricFactory({ id: 2 }), + ]; + const vlans = [vlanFactory({ fabric: 1 }), vlanFactory({ fabric: 1 })]; + const subnets = [subnetFactory(), subnetFactory()]; + const spaces = [spaceFactory()]; + const tableData = getTableData({ fabrics, vlans, subnets, spaces }, "fabric"); + + expect(groupRowsByFabric(tableData)[0].fabricId).toBe(fabrics[0].id); + expect(groupRowsByFabric(tableData)[0].networks[0]).toStrictEqual( + tableData[0] + ); +}); + +test("groupSubnetData returns grouped data in a correct format", () => { + const fabrics = [ + fabricFactory({ id: 1, vlan_ids: [1] }), + fabricFactory({ id: 2 }), + ]; + const vlans = [vlanFactory({ fabric: 1 }), vlanFactory({ fabric: 1 })]; + const subnets = [subnetFactory(), subnetFactory()]; + const spaces = [spaceFactory()]; + const tableData = getTableData({ fabrics, vlans, subnets, spaces }, "fabric"); + + expect(groupSubnetData(tableData)).toStrictEqual({ + "test-fabric-11": { count: 2 }, + "test-fabric-12": { count: 1 }, + }); + expect(groupSubnetData(tableData, "space")).toStrictEqual({ + "no space": { count: 3 }, + }); +}); diff --git a/src/app/subnets/views/SubnetsList/SubnetsTable/utils.tsx b/src/app/subnets/views/SubnetsList/SubnetsTable/utils.tsx index 7d8c645f85..978bb9cf4b 100644 --- a/src/app/subnets/views/SubnetsList/SubnetsTable/utils.tsx +++ b/src/app/subnets/views/SubnetsList/SubnetsTable/utils.tsx @@ -1,11 +1,11 @@ -import cloneDeep from "clone-deep"; - import { SubnetsColumns } from "./constants"; import type { SubnetsTableRow, SubnetsTableData, GroupByKey, SortData, + FabricTableRow, + SpaceTableRow, } from "./types"; import type { Fabric } from "app/store/fabric/types"; @@ -40,50 +40,79 @@ const getColumn = (label: string | null, href?: string | null) => ({ isVisuallyHidden: false, }); -export const groupRowsByFabricAndVlan = ( - sourceRows: SubnetsTableRow[] -): SubnetsTableRow[] => { - const rows: SubnetsTableRow[] = []; - +export const groupRowsByFabric = (sourceRows: SubnetsTableRow[]) => { + const rows: FabricTableRow[] = []; sourceRows.forEach((sourceRow, index) => { - const row = cloneDeep(sourceRow); - const previousRow = rows[index - 1]; - - if (row && index > 0) { - if (row.sortData?.fabricId === previousRow?.sortData?.fabricId) { - row.fabric = { ...row.fabric, isVisuallyHidden: true }; - } - if (row.sortData?.vlanId === previousRow?.sortData?.vlanId) { - row.vlan = { ...row.vlan, isVisuallyHidden: true }; + const previousRow = rows[rows.length - 1]; + + if (sourceRow && index > 0) { + if (sourceRow.sortData?.fabricId === previousRow?.fabricId) { + rows[rows.length - 1].networks.push(sourceRow); + } else { + rows.push({ + fabricId: sourceRow.sortData?.fabricId, + fabricName: sourceRow.sortData?.fabricName, + isCollapsed: false, + networks: [sourceRow], + }); } } - - rows.push(row); + if (index === 0) { + rows.push({ + fabricId: sourceRow.sortData?.fabricId, + fabricName: sourceRow.sortData?.fabricName, + isCollapsed: false, + networks: [sourceRow], + }); + } }); - return rows; }; -export const groupRowsBySpace = ( - sourceRows: SubnetsTableRow[] -): SubnetsTableRow[] => { - const rows: SubnetsTableRow[] = []; - +export const groupRowsBySpace = (sourceRows: SubnetsTableRow[]) => { + const rows: SpaceTableRow[] = []; sourceRows.forEach((sourceRow, index) => { - const row = cloneDeep(sourceRow); - const previousRow = rows[index - 1]; - - if (row && index > 0) { - if (row.space?.label === previousRow?.space?.label) { - row.space = { ...row.space, isVisuallyHidden: true }; + const previousRow = rows[rows.length - 1]; + + if (sourceRow && index > 0) { + if (sourceRow.sortData?.spaceName === previousRow?.spaceName) { + rows[rows.length - 1].networks.push(sourceRow); + } else { + rows.push({ + spaceName: sourceRow.sortData?.spaceName, + isCollapsed: false, + networks: [sourceRow], + }); } } - rows.push(row); + if (index === 0) { + rows.push({ + spaceName: sourceRow.sortData?.spaceName, + isCollapsed: false, + networks: [sourceRow], + }); + } }); - return rows; }; +export const groupSubnetData = ( + data: SubnetsTableRow[], + groupBy: GroupByKey = "fabric" +) => { + return data.reduce>((acc, cur) => { + const name = + groupBy === "fabric" ? cur.sortData?.fabricName : cur.sortData?.spaceName; + if (acc[name]) { + acc[name].count += 1; + } else { + acc[name] = { count: 1 }; + } + + return acc; + }, {}); +}; + const getRowData = ({ fabric, vlan, diff --git a/src/app/subnets/views/SubnetsList/_index.scss b/src/app/subnets/views/SubnetsList/_index.scss index b46be36c6b..81841438bb 100644 --- a/src/app/subnets/views/SubnetsList/_index.scss +++ b/src/app/subnets/views/SubnetsList/_index.scss @@ -1,4 +1,6 @@ @mixin SubnetsList { + $grouped-subnet-indentation: $sph--x-large; + .subnets-table { tr:not(:first-child) { border-top: 0 !important; @@ -61,4 +63,12 @@ width: 1px; } } + + .fabric-table, + .space-table { + @include truncated-border($width: $grouped-subnet-indentation); + .subnet-row td:first-child { + padding-left: $grouped-subnet-indentation; + } + } }