diff --git a/src/app/base/components/DhcpFormFields/MachineSelect/MachineSelectBox/MachineSelectBox.tsx b/src/app/base/components/DhcpFormFields/MachineSelect/MachineSelectBox/MachineSelectBox.tsx index f4dca2560f..5e34cf916b 100644 --- a/src/app/base/components/DhcpFormFields/MachineSelect/MachineSelectBox/MachineSelectBox.tsx +++ b/src/app/base/components/DhcpFormFields/MachineSelect/MachineSelectBox/MachineSelectBox.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import DebounceSearchBox from "@/app/base/components/DebounceSearchBox"; import { MachineSelectTable } from "@/app/base/components/MachineSelectTable/MachineSelectTable"; -import MachineListPagination from "@/app/machines/views/MachineList/MachineListTable/MachineListPagination"; +import MachineListPagination from "@/app/machines/components/MachineListPagination"; import type { FetchFilters, Machine } from "@/app/store/machine/types"; import { FilterGroupKey } from "@/app/store/machine/types"; import { useFetchMachines } from "@/app/store/machine/utils/hooks"; diff --git a/src/app/kvm/components/LXDVMsTable/LXDVMsTable.tsx b/src/app/kvm/components/LXDVMsTable/LXDVMsTable.tsx index 99dce59630..156c6fb441 100644 --- a/src/app/kvm/components/LXDVMsTable/LXDVMsTable.tsx +++ b/src/app/kvm/components/LXDVMsTable/LXDVMsTable.tsx @@ -10,7 +10,7 @@ import type { GetHostColumn, GetResources } from "./VMsTable/VMsTable"; import type { SetSearchFilter, SortDirection } from "@/app/base/types"; import type { KVMSetSidePanelContent } from "@/app/kvm/types"; -import { DEFAULTS } from "@/app/machines/views/MachineList/MachineListTable/constants"; +import { DEFAULTS } from "@/app/machines/views/MachinesList/constants"; import { machineActions } from "@/app/store/machine"; import type { FetchGroupKey } from "@/app/store/machine/types"; import { FilterGroupKey } from "@/app/store/machine/types"; diff --git a/src/app/kvm/components/LXDVMsTable/VMsTable/NameColumn/NameColumn.tsx b/src/app/kvm/components/LXDVMsTable/VMsTable/NameColumn/NameColumn.tsx index e7250237f2..8fc8aff2a2 100644 --- a/src/app/kvm/components/LXDVMsTable/VMsTable/NameColumn/NameColumn.tsx +++ b/src/app/kvm/components/LXDVMsTable/VMsTable/NameColumn/NameColumn.tsx @@ -4,7 +4,7 @@ import { Link } from "react-router"; import DoubleRow from "@/app/base/components/DoubleRow"; import urls from "@/app/base/urls"; -import MachineCheckbox from "@/app/machines/views/MachineList/MachineListTable/MachineCheckbox"; +import MachineCheckbox from "@/app/machines/components/MachineCheckbox"; import machineSelectors from "@/app/store/machine/selectors"; import type { Machine } from "@/app/store/machine/types"; import type { RootState } from "@/app/store/root/types"; diff --git a/src/app/kvm/components/LXDVMsTable/VMsTable/VMsTable.tsx b/src/app/kvm/components/LXDVMsTable/VMsTable/VMsTable.tsx index ee4b8d6edd..d4c546f067 100644 --- a/src/app/kvm/components/LXDVMsTable/VMsTable/VMsTable.tsx +++ b/src/app/kvm/components/LXDVMsTable/VMsTable/VMsTable.tsx @@ -15,7 +15,7 @@ import DoubleRow from "@/app/base/components/DoubleRow"; import Placeholder from "@/app/base/components/Placeholder"; import TableHeader from "@/app/base/components/TableHeader"; import { SortDirection } from "@/app/base/types"; -import AllCheckbox from "@/app/machines/views/MachineList/MachineListTable/AllCheckbox"; +import AllCheckbox from "@/app/machines/components/AllCheckbox"; import type { Machine } from "@/app/store/machine/types"; import { FilterGroupKey, FetchGroupKey } from "@/app/store/machine/types"; import { FilterMachines } from "@/app/store/machine/utils"; diff --git a/src/app/kvm/components/VmResources/VmResources.test.tsx b/src/app/kvm/components/VmResources/VmResources.test.tsx index 0c5b8c4431..0aa831315b 100644 --- a/src/app/kvm/components/VmResources/VmResources.test.tsx +++ b/src/app/kvm/components/VmResources/VmResources.test.tsx @@ -1,20 +1,13 @@ import * as reduxToolkit from "@reduxjs/toolkit"; -import configureStore from "redux-mock-store"; import VmResources, { Label } from "./VmResources"; -import { Label as MachineListLabel } from "@/app/machines/views/MachineList/MachineListTable/MachineListTable"; import { machineActions } from "@/app/store/machine"; import * as query from "@/app/store/machine/utils/query"; import { PodType } from "@/app/store/pod/constants"; import type { RootState } from "@/app/store/root/types"; import * as factory from "@/testing/factories"; -import { - userEvent, - screen, - renderWithBrowserRouter, - renderWithMockStore, -} from "@/testing/utils"; +import { userEvent, screen, renderWithProviders } from "@/testing/utils"; vi.mock("@reduxjs/toolkit", async () => { const actual: object = await vi.importActual("@reduxjs/toolkit"); @@ -24,7 +17,6 @@ vi.mock("@reduxjs/toolkit", async () => { }; }); const callId = "mocked-nanoid"; -const mockStore = configureStore(); describe("VmResources", () => { let state: RootState; @@ -67,17 +59,16 @@ describe("VmResources", () => { name: "Deployed", }), ]; - renderWithMockStore(, { state }); + renderWithProviders(, { state }); expect( screen.getByRole("button", { name: Label.ResourceVMs }) ).toBeAriaDisabled(); }); it("can pass additional filters to the request", () => { - const store = mockStore(state); - renderWithMockStore( + const { store } = renderWithProviders( , - { store } + { state } ); const expected = machineActions.fetch(callId); const result = store @@ -90,16 +81,12 @@ describe("VmResources", () => { }); it("can display a list of VMs", async () => { - renderWithBrowserRouter(, { + renderWithProviders(, { state, }); await userEvent.click( screen.getByRole("button", { name: Label.ResourceVMs }) ); - expect( - screen.getByRole("grid", { - name: new RegExp(MachineListLabel.Machines, "i"), - }) - ).toBeInTheDocument(); + expect(screen.getByRole("grid")).toBeInTheDocument(); }); }); diff --git a/src/app/kvm/components/VmResources/VmResources.tsx b/src/app/kvm/components/VmResources/VmResources.tsx index 54bb203856..b938613df8 100644 --- a/src/app/kvm/components/VmResources/VmResources.tsx +++ b/src/app/kvm/components/VmResources/VmResources.tsx @@ -1,63 +1,80 @@ +import type { ReactElement } from "react"; import { useState } from "react"; -import type { ValueOf } from "@canonical/react-components"; +import { GenericTable } from "@canonical/maas-react-components"; import { ContextualMenu } from "@canonical/react-components"; +import type { SortingState } from "@tanstack/react-table"; import { useSelector } from "react-redux"; -import type { SortDirection } from "@/app/base/types"; -import MachineListTable from "@/app/machines/views/MachineList/MachineListTable"; -import { DEFAULTS } from "@/app/machines/views/MachineList/MachineListTable/constants"; -import type { FetchFilters, FetchGroupKey } from "@/app/store/machine/types"; -import { FilterGroupKey } from "@/app/store/machine/types"; +import usePagination from "@/app/base/hooks/usePagination/usePagination"; +import type { Sort } from "@/app/base/types"; +import { SortKeyMapping } from "@/app/machines/components/MachinesTable/MachinesTable"; +import useMachinesTableColumns from "@/app/machines/components/MachinesTable/useMachinesTableColumns/useMachinesTableColumns"; +import type { FetchFilters } from "@/app/store/machine/types"; +import { FetchGroupKey, FilterGroupKey } from "@/app/store/machine/types"; import { useFetchedCount } from "@/app/store/machine/utils"; import { useFetchMachines } from "@/app/store/machine/utils/hooks"; import podSelectors from "@/app/store/pod/selectors"; import type { Pod } from "@/app/store/pod/types"; import type { RootState } from "@/app/store/root/types"; +import "./_index.scss"; + export enum Label { ResourceVMs = "Resource VMs", } -export type Props = { +export type VmResourcesProps = { filters?: FetchFilters; podId: Pod["id"]; }; export const VMS_PER_PAGE = 5; -const VmResources = ({ filters, podId }: Props): React.ReactElement => { +const VmResources = ({ filters, podId }: VmResourcesProps): ReactElement => { const pod = useSelector((state: RootState) => podSelectors.getById(state, podId) ); - const [currentPage, setCurrentPage] = useState(1); - const [sortKey, setSortKey] = useState( - DEFAULTS.sortKey - ); - const [sortDirection, setSortDirection] = useState< - ValueOf - >(DEFAULTS.sortDirection); + const [sorting, setSorting] = useState([ + { id: "hostname", desc: false }, + ]); + const sort: Sort = { + key: sorting.length ? SortKeyMapping[sorting[0].id] : null, + direction: sorting.length + ? sorting[0].desc + ? "descending" + : "ascending" + : "none", + }; + + const { page, size, handlePageSizeChange, setPage } = + usePagination(VMS_PER_PAGE); + const { - callId, loading, machineCount, machines: vms, groups, - totalPages, } = useFetchMachines({ filters: { ...filters, [FilterGroupKey.Pod]: pod ? [pod.name] : [], }, - sortDirection, - sortKey, + sortDirection: sort.direction, + sortKey: sort.key, pagination: { - currentPage, - setCurrentPage, + currentPage: page, + setCurrentPage: setPage, pageSize: VMS_PER_PAGE, }, }); + const count = useFetchedCount(machineCount, loading); + const columns = useMachinesTableColumns(FetchGroupKey.None, groups![0], { + ...filters, + [FilterGroupKey.Pod]: pod ? [pod.name] : [], + }); + return (
@@ -70,29 +87,34 @@ const VmResources = ({ filters, podId }: Props): React.ReactElement => { toggleLabel={`Total VMs: ${count ?? 0}`} toggleProps={{ position: "left", "aria-label": Label.ResourceVMs }} > - + column.id && + ![ + "group", + "owner", + "pool", + "zone", + "fabric", + "disks", + "storage", + ].includes(column.id) + )} + data={vms} + isLoading={loading} + pagination={{ + currentPage: page, + dataContext: "machines", + handlePageSizeChange: handlePageSizeChange, + isPending: false, + itemsPerPage: size, + setCurrentPage: setPage, + totalItems: machineCount ?? 0, + pageSizes: [VMS_PER_PAGE], + }} + setSorting={setSorting} + sorting={sorting} />
diff --git a/src/app/kvm/components/VmResources/_index.scss b/src/app/kvm/components/VmResources/_index.scss index e8804759e1..0231bc0e8b 100644 --- a/src/app/kvm/components/VmResources/_index.scss +++ b/src/app/kvm/components/VmResources/_index.scss @@ -14,14 +14,18 @@ 100vw - #{(map.get($grid-gutter-widths, small) + $sph--large) * 2} ); - .fqdn-col, - .status-col { - width: 50%; + .fqdn, + .status { + width: 14rem; } - .power-col, - .cores-col, - .ram-col { + .cpu { + text-align: right; + } + + .power, + .cpu, + .ram { display: none; } @@ -30,17 +34,17 @@ 100vw - #{(map.get($grid-gutter-widths, default) + $sph--large) * 2} ); - .power-col { + .power { display: table-cell; - width: 7rem; + width: 12rem; } } @media only screen and (min-width: $breakpoint-small) { - width: 40rem; + width: 50rem; - .cores-col, - .ram-col { + .cpu, + .ram { display: table-cell; width: 5rem; } diff --git a/src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllCheckbox.test.tsx b/src/app/machines/components/AllCheckbox/AllCheckbox.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllCheckbox.test.tsx rename to src/app/machines/components/AllCheckbox/AllCheckbox.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllCheckbox.tsx b/src/app/machines/components/AllCheckbox/AllCheckbox.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllCheckbox.tsx rename to src/app/machines/components/AllCheckbox/AllCheckbox.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllDropdown/AllDropdown.test.tsx b/src/app/machines/components/AllCheckbox/AllDropdown/AllDropdown.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllDropdown/AllDropdown.test.tsx rename to src/app/machines/components/AllCheckbox/AllDropdown/AllDropdown.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllDropdown/AllDropdown.tsx b/src/app/machines/components/AllCheckbox/AllDropdown/AllDropdown.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllDropdown/AllDropdown.tsx rename to src/app/machines/components/AllCheckbox/AllDropdown/AllDropdown.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllDropdown/index.ts b/src/app/machines/components/AllCheckbox/AllDropdown/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/AllCheckbox/AllDropdown/index.ts rename to src/app/machines/components/AllCheckbox/AllDropdown/index.ts diff --git a/src/app/machines/views/MachineList/MachineListTable/AllCheckbox/index.ts b/src/app/machines/components/AllCheckbox/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/AllCheckbox/index.ts rename to src/app/machines/components/AllCheckbox/index.ts diff --git a/src/app/machines/views/MachineList/MachineListTable/AllCheckbox/utils.ts b/src/app/machines/components/AllCheckbox/utils.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/AllCheckbox/utils.ts rename to src/app/machines/components/AllCheckbox/utils.ts diff --git a/src/app/machines/views/MachineList/ErrorsNotification/ErrorsNotification.test.tsx b/src/app/machines/components/ErrorsNotification/ErrorsNotification.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/ErrorsNotification/ErrorsNotification.test.tsx rename to src/app/machines/components/ErrorsNotification/ErrorsNotification.test.tsx diff --git a/src/app/machines/views/MachineList/ErrorsNotification/ErrorsNotification.tsx b/src/app/machines/components/ErrorsNotification/ErrorsNotification.tsx similarity index 100% rename from src/app/machines/views/MachineList/ErrorsNotification/ErrorsNotification.tsx rename to src/app/machines/components/ErrorsNotification/ErrorsNotification.tsx diff --git a/src/app/machines/views/MachineList/ErrorsNotification/index.ts b/src/app/machines/components/ErrorsNotification/index.ts similarity index 100% rename from src/app/machines/views/MachineList/ErrorsNotification/index.ts rename to src/app/machines/components/ErrorsNotification/index.ts diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineCheckbox/MachineCheckbox.test.tsx b/src/app/machines/components/MachineCheckbox/MachineCheckbox.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineCheckbox/MachineCheckbox.test.tsx rename to src/app/machines/components/MachineCheckbox/MachineCheckbox.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineCheckbox/MachineCheckbox.tsx b/src/app/machines/components/MachineCheckbox/MachineCheckbox.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineCheckbox/MachineCheckbox.tsx rename to src/app/machines/components/MachineCheckbox/MachineCheckbox.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineCheckbox/index.ts b/src/app/machines/components/MachineCheckbox/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineCheckbox/index.ts rename to src/app/machines/components/MachineCheckbox/index.ts diff --git a/src/app/machines/components/MachineForms/MachineActionFormWrapper/CloneForm/CloneFormFields/SourceMachineSelect/SourceMachineSelect.tsx b/src/app/machines/components/MachineForms/MachineActionFormWrapper/CloneForm/CloneFormFields/SourceMachineSelect/SourceMachineSelect.tsx index 21d0a4ccfa..85e14099b0 100644 --- a/src/app/machines/components/MachineForms/MachineActionFormWrapper/CloneForm/CloneFormFields/SourceMachineSelect/SourceMachineSelect.tsx +++ b/src/app/machines/components/MachineForms/MachineActionFormWrapper/CloneForm/CloneFormFields/SourceMachineSelect/SourceMachineSelect.tsx @@ -9,7 +9,7 @@ import SourceMachineDetails from "./SourceMachineDetails"; import DebounceSearchBox from "@/app/base/components/DebounceSearchBox"; import { MachineSelectTable } from "@/app/base/components/MachineSelectTable/MachineSelectTable"; import { useFetchActions } from "@/app/base/hooks"; -import MachineListPagination from "@/app/machines/views/MachineList/MachineListTable/MachineListPagination"; +import MachineListPagination from "@/app/machines/components/MachineListPagination"; import type { Machine, MachineDetails } from "@/app/store/machine/types"; import { FilterMachines } from "@/app/store/machine/utils"; import { useFetchMachines } from "@/app/store/machine/utils/hooks"; diff --git a/src/app/machines/views/MachineList/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.test.tsx b/src/app/machines/components/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.test.tsx rename to src/app/machines/components/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.tsx b/src/app/machines/components/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.tsx rename to src/app/machines/components/MachineListControls/HiddenColumnsSelect/HiddenColumnsSelect.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/HiddenColumnsSelect/index.ts b/src/app/machines/components/MachineListControls/HiddenColumnsSelect/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/HiddenColumnsSelect/index.ts rename to src/app/machines/components/MachineListControls/HiddenColumnsSelect/index.ts diff --git a/src/app/machines/views/MachineList/MachineListControls/MachineActionMenu/MachineActionMenu.stories.tsx b/src/app/machines/components/MachineListControls/MachineActionMenu/MachineActionMenu.stories.tsx similarity index 87% rename from src/app/machines/views/MachineList/MachineListControls/MachineActionMenu/MachineActionMenu.stories.tsx rename to src/app/machines/components/MachineListControls/MachineActionMenu/MachineActionMenu.stories.tsx index 2a742812f9..ed98fff418 100644 --- a/src/app/machines/views/MachineList/MachineListControls/MachineActionMenu/MachineActionMenu.stories.tsx +++ b/src/app/machines/components/MachineListControls/MachineActionMenu/MachineActionMenu.stories.tsx @@ -1,6 +1,6 @@ import type { Meta } from "@storybook/react"; -import MachineActionMenu from "."; +import MachineActionMenu from "./index"; const meta: Meta = { title: "Sections/Machine/MachineActionMenu", diff --git a/src/app/machines/views/MachineList/MachineListControls/MachineActionMenu/MachineActionMenu.tsx b/src/app/machines/components/MachineListControls/MachineActionMenu/MachineActionMenu.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachineActionMenu/MachineActionMenu.tsx rename to src/app/machines/components/MachineListControls/MachineActionMenu/MachineActionMenu.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/MachineActionMenu/index.ts b/src/app/machines/components/MachineListControls/MachineActionMenu/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachineActionMenu/index.ts rename to src/app/machines/components/MachineListControls/MachineActionMenu/index.ts diff --git a/src/app/machines/views/MachineList/MachineListControls/MachineListControls.test.tsx b/src/app/machines/components/MachineListControls/MachineListControls.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachineListControls.test.tsx rename to src/app/machines/components/MachineListControls/MachineListControls.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/MachineListControls.tsx b/src/app/machines/components/MachineListControls/MachineListControls.tsx similarity index 89% rename from src/app/machines/views/MachineList/MachineListControls/MachineListControls.tsx rename to src/app/machines/components/MachineListControls/MachineListControls.tsx index 170e1d3ba7..087f6984e7 100644 --- a/src/app/machines/views/MachineList/MachineListControls/MachineListControls.tsx +++ b/src/app/machines/components/MachineListControls/MachineListControls.tsx @@ -9,13 +9,13 @@ import { Link } from "react-router"; import DebounceSearchBox from "@/app/base/components/DebounceSearchBox"; import GroupSelect from "@/app/base/components/GroupSelect"; import urls from "@/app/base/urls"; +import HiddenColumnsSelect from "@/app/machines/components/MachineListControls/HiddenColumnsSelect"; +import MachineActionMenu from "@/app/machines/components/MachineListControls/MachineActionMenu"; +import MachinesFilterAccordion from "@/app/machines/components/MachineListControls/MachinesFilterAccordion"; +import AddHardwareMenu from "@/app/machines/components/MachineListHeader/AddHardwareMenu"; import { groupOptions } from "@/app/machines/constants"; import type { MachineSetSidePanelContent } from "@/app/machines/types"; -import HiddenColumnsSelect from "@/app/machines/views/MachineList/MachineListControls/HiddenColumnsSelect"; -import MachineActionMenu from "@/app/machines/views/MachineList/MachineListControls/MachineActionMenu"; -import MachinesFilterAccordion from "@/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion"; -import AddHardwareMenu from "@/app/machines/views/MachineList/MachineListHeader/AddHardwareMenu"; -import type { useResponsiveColumns } from "@/app/machines/views/MachineList/hooks"; +import type { useResponsiveColumns } from "@/app/machines/views/MachinesList/hooks"; import { machineActions } from "@/app/store/machine"; import type { FetchGroupKey } from "@/app/store/machine/types"; import { useHasSelection } from "@/app/store/machine/utils/hooks"; diff --git a/src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.test.tsx b/src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.test.tsx rename to src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.tsx b/src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.tsx rename to src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterAccordion.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.test.tsx b/src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.test.tsx rename to src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.tsx b/src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.tsx rename to src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/MachinesFilterOptions.tsx diff --git a/src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/index.ts b/src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/index.ts rename to src/app/machines/components/MachineListControls/MachinesFilterAccordion/MachinesFilterOptions/index.ts diff --git a/src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/index.ts b/src/app/machines/components/MachineListControls/MachinesFilterAccordion/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/MachinesFilterAccordion/index.ts rename to src/app/machines/components/MachineListControls/MachinesFilterAccordion/index.ts diff --git a/src/app/machines/views/MachineList/MachineListControls/_index.scss b/src/app/machines/components/MachineListControls/_index.scss similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/_index.scss rename to src/app/machines/components/MachineListControls/_index.scss diff --git a/src/app/machines/views/MachineList/MachineListControls/index.ts b/src/app/machines/components/MachineListControls/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListControls/index.ts rename to src/app/machines/components/MachineListControls/index.ts diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListGroupCount/MachineListGroupCount.test.tsx b/src/app/machines/components/MachineListGroupCount/MachineListGroupCount.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineListGroupCount/MachineListGroupCount.test.tsx rename to src/app/machines/components/MachineListGroupCount/MachineListGroupCount.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListGroupCount/MachineListGroupCount.tsx b/src/app/machines/components/MachineListGroupCount/MachineListGroupCount.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineListGroupCount/MachineListGroupCount.tsx rename to src/app/machines/components/MachineListGroupCount/MachineListGroupCount.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListGroupCount/index.ts b/src/app/machines/components/MachineListGroupCount/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineListGroupCount/index.ts rename to src/app/machines/components/MachineListGroupCount/index.ts diff --git a/src/app/machines/views/MachineList/MachineListHeader/AddHardwareMenu/AddHardwareMenu.test.tsx b/src/app/machines/components/MachineListHeader/AddHardwareMenu/AddHardwareMenu.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListHeader/AddHardwareMenu/AddHardwareMenu.test.tsx rename to src/app/machines/components/MachineListHeader/AddHardwareMenu/AddHardwareMenu.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListHeader/AddHardwareMenu/AddHardwareMenu.tsx b/src/app/machines/components/MachineListHeader/AddHardwareMenu/AddHardwareMenu.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListHeader/AddHardwareMenu/AddHardwareMenu.tsx rename to src/app/machines/components/MachineListHeader/AddHardwareMenu/AddHardwareMenu.tsx diff --git a/src/app/machines/views/MachineList/MachineListHeader/AddHardwareMenu/index.ts b/src/app/machines/components/MachineListHeader/AddHardwareMenu/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListHeader/AddHardwareMenu/index.ts rename to src/app/machines/components/MachineListHeader/AddHardwareMenu/index.ts diff --git a/src/app/machines/views/MachineList/MachineListHeader/MachineListHeader.test.tsx b/src/app/machines/components/MachineListHeader/MachineListHeader.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListHeader/MachineListHeader.test.tsx rename to src/app/machines/components/MachineListHeader/MachineListHeader.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListHeader/MachineListHeader.tsx b/src/app/machines/components/MachineListHeader/MachineListHeader.tsx similarity index 96% rename from src/app/machines/views/MachineList/MachineListHeader/MachineListHeader.tsx rename to src/app/machines/components/MachineListHeader/MachineListHeader.tsx index c1728d39dc..9c4b6b8d18 100644 --- a/src/app/machines/views/MachineList/MachineListHeader/MachineListHeader.tsx +++ b/src/app/machines/components/MachineListHeader/MachineListHeader.tsx @@ -2,8 +2,8 @@ import { useCallback } from "react"; import { useDispatch } from "react-redux"; +import type { useResponsiveColumns } from "../../views/MachinesList/hooks"; import MachineListControls from "../MachineListControls"; -import type { useResponsiveColumns } from "../hooks"; import { usePoolCount } from "@/app/api/query/pools"; import type { SetSearchFilter } from "@/app/base/types"; diff --git a/src/app/machines/views/MachineList/MachineListHeader/index.ts b/src/app/machines/components/MachineListHeader/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListHeader/index.ts rename to src/app/machines/components/MachineListHeader/index.ts diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.test.tsx b/src/app/machines/components/MachineListPagination/MachineListPagination.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.test.tsx rename to src/app/machines/components/MachineListPagination/MachineListPagination.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.tsx b/src/app/machines/components/MachineListPagination/MachineListPagination.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineListPagination/MachineListPagination.tsx rename to src/app/machines/components/MachineListPagination/MachineListPagination.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/_index.scss b/src/app/machines/components/MachineListPagination/_index.scss similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineListPagination/_index.scss rename to src/app/machines/components/MachineListPagination/_index.scss diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListPagination/index.ts b/src/app/machines/components/MachineListPagination/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineListPagination/index.ts rename to src/app/machines/components/MachineListPagination/index.ts diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineTestStatus/MachineTestStatus.test.tsx b/src/app/machines/components/MachineTestStatus/MachineTestStatus.test.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineTestStatus/MachineTestStatus.test.tsx rename to src/app/machines/components/MachineTestStatus/MachineTestStatus.test.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineTestStatus/MachineTestStatus.tsx b/src/app/machines/components/MachineTestStatus/MachineTestStatus.tsx similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineTestStatus/MachineTestStatus.tsx rename to src/app/machines/components/MachineTestStatus/MachineTestStatus.tsx diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineTestStatus/index.ts b/src/app/machines/components/MachineTestStatus/index.ts similarity index 100% rename from src/app/machines/views/MachineList/MachineListTable/MachineTestStatus/index.ts rename to src/app/machines/components/MachineTestStatus/index.ts diff --git a/src/app/machines/components/MachinesTable/MachinesTable.test.tsx b/src/app/machines/components/MachinesTable/MachinesTable.test.tsx new file mode 100644 index 0000000000..914a9c6bd5 --- /dev/null +++ b/src/app/machines/components/MachinesTable/MachinesTable.test.tsx @@ -0,0 +1,464 @@ +import userEvent from "@testing-library/user-event"; +import { describe } from "vitest"; + +import MachinesTable from "./MachinesTable"; + +import { DEFAULTS } from "@/app/machines/views/MachinesList/constants"; +import * as query from "@/app/store/machine/utils/query"; +import type { RootState } from "@/app/store/root/types"; +import { + FetchNodeStatus, + NodeStatus, + NodeStatusCode, + TestStatusStatus, +} from "@/app/store/types/node"; +import { DeleteZone, EditZone } from "@/app/zones/components"; +import * as factory from "@/testing/factories"; +import { poolsResolvers } from "@/testing/resolvers/pools"; +import { usersResolvers } from "@/testing/resolvers/users"; +import { zoneResolvers } from "@/testing/resolvers/zones"; +import { + renderWithProviders, + screen, + waitFor, + setupMockServer, + within, + mockSidePanel, +} from "@/testing/utils"; + +const mockServer = setupMockServer( + usersResolvers.listUsers.handler(), + poolsResolvers.listPools.handler(), + zoneResolvers.listZones.handler() +); +const { mockOpen } = await mockSidePanel(); + +describe("MachinesTable", () => { + let state: RootState; + + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(query, "generateCallId").mockReturnValue("123456"); + const machines = [ + factory.machine({ + actions: [], + architecture: "amd64/generic", + cpu_count: 4, + cpu_test_status: factory.testStatus({ + status: TestStatusStatus.RUNNING, + }), + distro_series: "bionic", + domain: factory.modelRef({ + name: "example", + }), + extra_macs: [], + fqdn: "koala.example", + hostname: "koala", + ip_addresses: [], + memory: 8, + memory_test_status: factory.testStatus({ + status: TestStatusStatus.PASSED, + }), + network_test_status: factory.testStatus({ + status: TestStatusStatus.PASSED, + }), + osystem: "ubuntu", + owner: "admin", + physical_disk_count: 1, + pool: factory.modelRef(), + pxe_mac: "00:11:22:33:44:55", + spaces: [], + status: NodeStatus.DEPLOYED, + status_code: NodeStatusCode.DEPLOYED, + status_message: "", + storage: 8, + storage_test_status: factory.testStatus({ + status: TestStatusStatus.PASSED, + }), + testing_status: TestStatusStatus.PASSED, + system_id: "abc123", + zone: factory.modelRef(), + }), + factory.machine({ + actions: [], + architecture: "amd64/generic", + cpu_count: 2, + cpu_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + distro_series: "xenial", + domain: factory.modelRef({ + name: "example", + }), + extra_macs: [], + fqdn: "other.example", + hostname: "other", + ip_addresses: [], + memory: 6, + memory_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + network_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + osystem: "ubuntu", + owner: "user", + physical_disk_count: 2, + pool: factory.modelRef(), + pxe_mac: "66:77:88:99:00:11", + spaces: [], + status: NodeStatus.RELEASING, + status_code: NodeStatusCode.RELEASING, + status_message: "", + storage: 16, + storage_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + testing_status: TestStatusStatus.FAILED, + system_id: "def456", + zone: factory.modelRef(), + }), + factory.machine({ + actions: [], + architecture: "amd64/generic", + cpu_count: 2, + cpu_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + distro_series: "xenial", + domain: factory.modelRef({ + name: "example", + }), + extra_macs: [], + fqdn: "other.example", + hostname: "other", + ip_addresses: [], + memory: 6, + memory_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + network_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + osystem: "ubuntu", + owner: "user", + physical_disk_count: 2, + pool: factory.modelRef(), + pxe_mac: "66:77:88:99:00:11", + spaces: [], + status: NodeStatus.FAILED_TESTING, + status_code: NodeStatusCode.DEPLOYED, + status_message: "", + storage: 16, + storage_test_status: factory.testStatus({ + status: TestStatusStatus.FAILED, + }), + testing_status: TestStatusStatus.FAILED, + system_id: "ghi789", + zone: factory.modelRef(), + }), + ]; + state = factory.rootState({ + general: factory.generalState({ + machineActions: { + data: [], + errors: null, + loaded: false, + loading: false, + }, + vaultEnabled: factory.vaultEnabledState({ data: false, loaded: true }), + osInfo: { + data: factory.osInfo({ + osystems: [["ubuntu", "Ubuntu"]], + releases: [["ubuntu/bionic", 'Ubuntu 18.04 LTS "Bionic Beaver"']], + }), + errors: null, + loaded: true, + loading: false, + }, + }), + controller: factory.controllerState({ + loaded: true, + items: [factory.controller({ vault_configured: false })], + }), + machine: factory.machineState({ + items: machines, + lists: { + "123456": factory.machineStateList({ + loaded: true, + groups: [ + factory.machineStateListGroup({ + items: [machines[0].system_id], + name: "Deployed", + value: FetchNodeStatus.DEPLOYED, + }), + factory.machineStateListGroup({ + items: [machines[1].system_id], + name: "Releasing", + value: FetchNodeStatus.RELEASING, + }), + factory.machineStateListGroup({ + items: [machines[2].system_id], + name: "Failed testing", + value: FetchNodeStatus.FAILED_TESTING, + }), + ], + }), + }, + filters: [factory.machineFilterGroup()], + filtersLoaded: true, + }), + }); + }); + + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe("display", () => { + it("displays a loading component if machines are loading", async () => { + state.machine.lists["123456"].loading = true; + renderWithProviders( + , + { state } + ); + + await waitFor(() => { + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + }); + + describe("displays a message when rendering an empty list", () => { + it("displays a message when there are no machines", async () => { + state.machine.lists = {}; + renderWithProviders( + , + { state } + ); + + await waitFor(() => { + expect(screen.getByText("No machines found.")).toBeInTheDocument(); + }); + }); + + it("displays a message when there are no search matches", async () => { + state.machine.lists = {}; + renderWithProviders( + , + { state } + ); + + await waitFor(() => { + expect( + screen.getByText("No machines match the search criteria.") + ).toBeInTheDocument(); + }); + }); + }); + + it("displays the columns correctly", () => { + renderWithProviders( + , + { state } + ); + + const headerRow = screen.getAllByRole("rowgroup")[0]; + + [ + "FQDN", + "Power", + "Status", + "Owner", + "Pool", + "Zone", + "Fabric", + "Cores", + "RAM", + "Disk", + "Storage", + ].forEach((column) => { + expect( + within(headerRow).getByText(new RegExp(`^${column}`, "i")) + ).toBeInTheDocument(); + }); + }); + + it("can show a machine filter link", async () => { + mockServer.use( + zoneResolvers.listZones.handler({ + items: [ + factory.zone({ + name: "default", + machines_count: 5, + devices_count: 2, + controllers_count: 1, + }), + ], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + within( + screen.getByRole("row", { + name: new RegExp(`^default`, "i"), + }) + ).getByRole("link", { name: "5" }) + ).toHaveAttribute("href", "/machines?zone=default"); + }); + }); + + it("can show a device filter link", async () => { + mockServer.use( + zoneResolvers.listZones.handler({ + items: [ + factory.zone({ + name: "default", + machines_count: 5, + devices_count: 2, + controllers_count: 1, + }), + ], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + within( + screen.getByRole("row", { + name: new RegExp(`^default`, "i"), + }) + ).getByRole("link", { name: "2" }) + ).toHaveAttribute("href", "/devices?zone=default"); + }); + }); + + it("can show a controller filter link", async () => { + mockServer.use( + zoneResolvers.listZones.handler({ + items: [ + factory.zone({ + name: "default", + machines_count: 5, + devices_count: 2, + controllers_count: 1, + }), + ], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + within( + screen.getByRole("row", { + name: new RegExp(`^default`, "i"), + }) + ).getByRole("link", { name: "1" }) + ).toHaveAttribute("href", "/controllers"); + }); + }); + }); + + // TODO: backend-provided permissions is only available for pools, + // and will be discussed as to whether they should be added everywhere. + // Enable these tests if they are added to zones + describe("permissions", () => { + it.todo("enables the action buttons with correct permissions"); + + it.todo("disables the action buttons without permissions"); + + it("disables the delete button for default zones", async () => { + mockServer.use( + zoneResolvers.listZones.handler({ + items: [ + factory.zone({ + id: 1, + name: "default", + description: "default", + }), + ], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Delete" }) + ).toBeAriaDisabled(); + }); + }); + }); + + describe("actions", () => { + it("opens edit zones side panel form", async () => { + mockServer.use( + zoneResolvers.listZones.handler({ + items: [factory.zone({ id: 1 })], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Edit" }) + ).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Edit" })); + + expect(mockOpen).toHaveBeenCalledWith({ + component: EditZone, + title: "Edit AZ", + props: { id: 1 }, + }); + }); + + it("opens delete zone side panel form", async () => { + mockServer.use( + zoneResolvers.listZones.handler({ + items: [ + factory.zone({ + id: 2, + }), + ], + total: 1, + }) + ); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Delete" }) + ).toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("button", { name: "Delete" })); + + expect(mockOpen).toHaveBeenCalledWith({ + component: DeleteZone, + title: "Delete AZ", + props: { id: 2 }, + }); + }); + }); +}); diff --git a/src/app/machines/components/MachinesTable/MachinesTable.tsx b/src/app/machines/components/MachinesTable/MachinesTable.tsx new file mode 100644 index 0000000000..a732659992 --- /dev/null +++ b/src/app/machines/components/MachinesTable/MachinesTable.tsx @@ -0,0 +1,167 @@ +import type { ReactElement } from "react"; +import { useMemo, useEffect, useState } from "react"; + +import { GenericTable } from "@canonical/maas-react-components"; +import type { RowSelectionState, SortingState } from "@tanstack/react-table"; +import classNames from "classnames"; +import { useDispatch, useSelector } from "react-redux"; + +import VaultNotification from "@/app/base/components/VaultNotification"; +import usePagination from "@/app/base/hooks/usePagination/usePagination"; +import ErrorsNotification from "@/app/machines/components/ErrorsNotification"; +import useMachinesTableColumns, { + filterCells, + filterHeaders, +} from "@/app/machines/components/MachinesTable/useMachinesTableColumns/useMachinesTableColumns"; +import type { useResponsiveColumns } from "@/app/machines/views/MachinesList/hooks"; +import { machineActions } from "@/app/store/machine"; +import machineSelectors from "@/app/store/machine/selectors"; +import type { FetchFilters, Machine } from "@/app/store/machine/types"; +import { FetchGroupKey } from "@/app/store/machine/types"; +import { useFetchMachines } from "@/app/store/machine/utils/hooks"; + +import "./index.scss"; + +type MachinesTableProps = { + grouping: FetchGroupKey; + hiddenColumns: ReturnType[0]; + hiddenGroups: (string | null)[]; + headerFormOpen?: boolean; + searchFilter?: FetchFilters; +}; + +export const SortKeyMapping: Record = { + hostname: FetchGroupKey.Hostname, + mac: FetchGroupKey.Hostname, + power: FetchGroupKey.PowerState, + status: FetchGroupKey.Status, + owner: FetchGroupKey.Owner, + ownerName: FetchGroupKey.Owner, + pool: FetchGroupKey.Pool, + zone: FetchGroupKey.Zone, + cpuCount: FetchGroupKey.CpuCount, + ram: FetchGroupKey.Memory, + disks: FetchGroupKey.PhysicalDiskCount, + storage: FetchGroupKey.TotalStorage, +}; + +const MachinesTable = ({ + grouping, + hiddenColumns, + hiddenGroups, + headerFormOpen, + searchFilter, +}: MachinesTableProps): ReactElement => { + const dispatch = useDispatch(); + const { page, size, handlePageSizeChange, setPage } = usePagination(); + const errors = useSelector(machineSelectors.errors); + + const [sorting, setSorting] = useState([ + { id: grouping, desc: false }, + ]); + + useEffect(() => { + setSorting([{ id: grouping, desc: false }]); + }, [grouping]); + + const [rowSelection, setRowSelection] = useState({}); + + const { callId, groups, loading, machineCount, machines, machinesErrors } = + useFetchMachines({ + collapsedGroups: hiddenGroups, + filters: searchFilter, + grouping, + sortDirection: sorting.length + ? sorting[0].desc + ? "descending" + : "ascending" + : "none", + sortKey: sorting.length ? SortKeyMapping[sorting[0].id] : null, + pagination: { + currentPage: page, + setCurrentPage: setPage, + pageSize: size, + }, + }); + + // TODO: the grouping and sorting in-table has a race condition with the redux store fetching, try fixing with v3 + const activeGroupBy = useMemo(() => { + // Don't group while loading or if no groups exist + if (loading || !groups || groups.length === 0) { + return []; + } + + // Extract all valid group names + const groupNames = groups.map((g) => g.name); + + // Check that every machine's groupField matches one of the group names + const hasMatchingGrouping = machines.every((machine) => { + const value = machine[grouping as keyof Machine]; + const groupField = + typeof value === "string" + ? value + : value && typeof value === "object" && "name" in value + ? (value as { name: string }).name + : undefined; + + return typeof groupField === "string" && groupNames.includes(groupField); + }); + + return hasMatchingGrouping ? [grouping] : []; + }, [loading, groups, grouping, machines]); + + const columns = useMachinesTableColumns(grouping); + + return ( + <> + {errors && !headerFormOpen ? ( + dispatch(machineActions.cleanup())} + /> + ) : null} + {!headerFormOpen ? : null} + + + !hiddenColumns.includes(column.id) && filterCells(row, column) + } + filterHeaders={(header) => + !hiddenColumns.includes(header.column.id) && filterHeaders(header) + } + groupBy={activeGroupBy} + isLoading={loading} + key={callId} + noData={ + searchFilter + ? "No machines match the search criteria." + : "No machines found." + } + pagination={{ + currentPage: page, + dataContext: "machines", + handlePageSizeChange: handlePageSizeChange, + isPending: false, + itemsPerPage: size, + setCurrentPage: setPage, + totalItems: machineCount ?? 0, + }} + pinGroup={groups.map((g) => ({ value: g.name ?? "", isTop: true }))} + selection={{ + rowSelection, + setRowSelection, + }} + setSorting={setSorting} + showChevron + variant="full-height" + /> + + ); +}; + +export default MachinesTable; diff --git a/src/app/machines/components/MachinesTable/index.scss b/src/app/machines/components/MachinesTable/index.scss new file mode 100644 index 0000000000..df94b05dc6 --- /dev/null +++ b/src/app/machines/components/MachinesTable/index.scss @@ -0,0 +1,44 @@ +.p-generic-table.machines-table { + margin-bottom: 0; + + th,td { + &.fqdn { + width: 15%; + .p-double-row__secondary-row { + text-align: left; + } + } + + &.power { + width: 12%; + } + + &.status { + width: 15%; + } + + &.owner { + width: 10%; + } + + &.pool, &.zone, &.fabric { + width: 8%; + button { + text-align: left; + } + } + + &.cpu, &.memory, &.disks, &.storage { + width: 6%; + text-align: right; + } + } + + &.has-hidden-columns { + th,td { + &.fqdn { + width: auto; + } + } + } +} \ No newline at end of file diff --git a/src/app/machines/components/MachinesTable/index.ts b/src/app/machines/components/MachinesTable/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/machines/components/MachinesTable/useMachinesTableColumns/useMachinesTableColumns.tsx b/src/app/machines/components/MachinesTable/useMachinesTableColumns/useMachinesTableColumns.tsx new file mode 100644 index 0000000000..7579ccae1c --- /dev/null +++ b/src/app/machines/components/MachinesTable/useMachinesTableColumns/useMachinesTableColumns.tsx @@ -0,0 +1,987 @@ +import type { Dispatch, ReactNode } from "react"; +import { useMemo, useState } from "react"; + +import { formatBytes } from "@canonical/maas-react-components"; +import type { MenuLink } from "@canonical/react-components"; +import { Button, Icon, Spinner, Tooltip } from "@canonical/react-components"; +import type { Column, ColumnDef, Header, Row } from "@tanstack/react-table"; +import pluralize from "pluralize"; +import { useDispatch, useSelector } from "react-redux"; +import { Link } from "react-router"; +import type { UnknownAction } from "redux"; + +import { usePools } from "@/app/api/query/pools"; +import { useUsers } from "@/app/api/query/users"; +import { useZones } from "@/app/api/query/zones"; +import type { + ResourcePoolWithSummaryResponse, + ZoneWithSummaryResponse, +} from "@/app/apiclient"; +import DoubleRow from "@/app/base/components/DoubleRow"; +import MacAddressDisplay from "@/app/base/components/MacAddressDisplay"; +import NonBreakingSpace from "@/app/base/components/NonBreakingSpace"; +import PowerIcon from "@/app/base/components/PowerIcon"; +import TooltipButton from "@/app/base/components/TooltipButton"; +import type { MachineMenuAction } from "@/app/base/hooks/node"; +import urls from "@/app/base/urls"; +import MachineTestStatus from "@/app/machines/components/MachineTestStatus"; +import { useToggleMenu } from "@/app/machines/hooks"; +import { PowerTypeNames } from "@/app/store/general/constants"; +import { + machineActions as machineActionsSelectors, + osInfo as osInfoSelectors, +} from "@/app/store/general/selectors"; +import type { OSInfoOptions } from "@/app/store/general/selectors/osInfo"; +import type { MachineAction } from "@/app/store/general/types"; +import { machineActions } from "@/app/store/machine"; +import type { Machine, FetchGroupKey } from "@/app/store/machine/types"; +import { isTransientStatus } from "@/app/store/machine/utils"; +import { isUnconfiguredPowerType } from "@/app/store/machine/utils/common"; +import type { RootState } from "@/app/store/root/types"; +import tagSelectors from "@/app/store/tag/selectors"; +import type { Tag } from "@/app/store/tag/types"; +import { getTagsDisplay } from "@/app/store/tag/utils"; +import { PowerState } from "@/app/store/types/enum"; +import { + NodeActions, + NodeStatusCode, + TestStatusStatus, +} from "@/app/store/types/node"; +import { + breakLines, + isEphemerallyDeployed, + kebabToCamelCase, +} from "@/app/utils"; + +// Node statuses for which the failed test warning is not shown. +const hideFailedTestWarningStatuses = [ + NodeStatusCode.COMMISSIONING, + NodeStatusCode.FAILED_COMMISSIONING, + NodeStatusCode.FAILED_TESTING, + NodeStatusCode.NEW, + NodeStatusCode.TESTING, +]; + +// Node statuses for which the OS + release is made human-readable. +const formattedReleaseStatuses = [ + NodeStatusCode.DEPLOYED, + NodeStatusCode.DEPLOYING, +]; + +export type MachineColumnDef = ColumnDef>; + +export const filterCells = ( + row: Row, + column: Column +): boolean => { + if (row.getIsGrouped()) { + return ["group"].includes(column.id); + } else { + return !["group"].includes(column.id); + } +}; + +export const filterHeaders = (header: Header): boolean => + header.column.id !== "group"; + +const getStatusText = ( + machine: Machine, + osReleases: OSInfoOptions, + osReleasesLoading: boolean +): string => { + if (!machine) { + return "Unknown"; + } + + let release: string; + if ( + !machine || + !machine.osystem || + !machine.distro_series || + osReleasesLoading + ) { + release = ""; + } else { + const machineRelease = Object.values(osReleases) + .flat() + .find((release) => release.value === machine.distro_series); + if (machineRelease) { + release = + machine.osystem === "ubuntu" + ? machineRelease.label.split('"')[0].trim() + : machineRelease.label; + } + release = `${machine.osystem}/${machine.distro_series}`; + } + + if (release && formattedReleaseStatuses.includes(machine.status_code)) { + return machine.status_code === NodeStatusCode.DEPLOYING + ? `Deploying ${release}` + : release; + } + return machine.status; +}; + +const getMachineActionLinks = ( + actions: MachineMenuAction[], + generalMachineActions: MachineAction[], + dispatch: Dispatch, + machine: Machine, + noneMessage?: string +) => { + const actionLinks: MenuLink = actions.map((action) => { + let actionLabel = action.toString(); + generalMachineActions.forEach((machineAction) => { + if (machineAction.name === action) { + actionLabel = machineAction.title; + } + }); + return { + children: actionLabel, + onClick: () => { + const actionMethod = kebabToCamelCase(action); + const actionFunction = machineActions[actionMethod]; + if (actionFunction) { + dispatch(actionFunction({ system_id: machine.system_id })); + } + }, + }; + }); + if (actionLinks.length === 0 && noneMessage) { + return [ + { + children: noneMessage, + disabled: true, + }, + ]; + } + return actionLinks; +}; + +const getMachineTags = (machine: Machine, tags: Tag[]): Tag[] => { + return tags.filter((tag) => machine.tags.includes(tag.id)); +}; + +const getPoolLinks = ( + machine: Machine, + resourcePools: ResourcePoolWithSummaryResponse[], + dispatch: Dispatch +) => { + let poolLinks; + const machinePools = resourcePools.filter( + (pool) => pool.id !== machine?.pool.id + ); + if (machine?.actions.includes(NodeActions.SET_POOL)) { + if (machinePools?.length !== 0) { + poolLinks = machinePools?.map((pool) => ({ + children: pool.name, + "data-testid": "change-pool-link", + onClick: () => { + dispatch( + machineActions.setPool({ + pool_id: pool.id, + system_id: machine.system_id, + }) + ); + }, + })); + } else { + poolLinks = [{ children: "No other pools available.", disabled: true }]; + } + } else { + poolLinks = [ + { children: "Cannot change pool of this machine.", disabled: true }, + ]; + } + return poolLinks; +}; + +const getZoneLinks = ( + machine: Machine, + zones: ZoneWithSummaryResponse[], + dispatch: Dispatch +) => { + let zoneLinks; + const machineZones = zones.filter((zone) => zone.id !== machine?.zone.id); + if (machine?.actions.includes(NodeActions.SET_ZONE)) { + if (machineZones?.length !== 0) { + zoneLinks = machineZones?.map((zone) => ({ + children: zone.name, + "data-testid": "change-zone-link", + onClick: () => { + dispatch( + machineActions.setZone({ + system_id: machine.system_id, + zone_id: zone.id, + }) + ); + }, + })); + } else { + zoneLinks = [{ children: "No other zones available", disabled: true }]; + } + } else { + zoneLinks = [ + { children: "Cannot change zone of this machine", disabled: true }, + ]; + } + return zoneLinks; +}; + +const getSpaces = (machine: Machine) => { + if (machine.spaces.length > 1) { + const sorted = [...machine.spaces].sort(); + return ( + + {`${machine.spaces.length} spaces`} + + ); + } + return ( + + {machine.spaces[0]} + + ); +}; + +const useMachinesTableColumns = ( + grouping: FetchGroupKey +): MachineColumnDef[] => { + const dispatch = useDispatch(); + const generalMachineActions = useSelector(machineActionsSelectors.get); + const toggleMenu = useToggleMenu(null); + const [showMAC, setShowMAC] = useState(false); + const [showFullName, setShowFullName] = useState(true); + + const osReleasesLoading = useSelector(osInfoSelectors.loading); + const osReleases = useSelector((state: RootState) => + osInfoSelectors.getAllOsReleases(state) + ); + + const tags = useSelector((state: RootState) => tagSelectors.all(state)); + const { data: users } = useUsers(); + const { data: resourcePools } = usePools(); + const { data: zones } = useZones(); + + return useMemo( + () => + [ + { + id: "group", + accessorKey: grouping, + enableSorting: false, + cell: ({ row }: { row: Row }) => { + if (!row.getIsGrouped()) return null; + const groupField = row.original[grouping as keyof Machine]!; + return ( + + {typeof groupField === "object" && "name" in groupField + ? groupField.name + : groupField.toString()} + + } + secondary={ + + {pluralize("machine", row.getLeafRows().length, true)} + + } + /> + ); + }, + }, + { + id: "fqdn", + accessorKey: "fqdn", + meta: { isInteractiveHeader: true }, + // TODO: enable sorting by sub-headers (e.g. fqdn/pxe_mac, owner/ownerName) with v3 + header: (header) => { + return ( + <> + +  |  + + {{ + asc: ascending, + desc: descending, + }[header?.column?.getIsSorted() as string] ?? null} +
+ IP + + ); + }, + cell: ({ row: { original: machine } }: { row: Row }) => { + const machineURL = urls.machines.machine.index({ + id: machine.system_id, + }); + const ipAddresses: string[] = []; + let bootIP; + + (machine.ip_addresses || []).forEach((address) => { + let ip = address.ip; + if (address.is_boot) { + ip = `${ip} (PXE)`; + bootIP = ip; + } + if (!ipAddresses.includes(ip)) { + ipAddresses.push(ip); + } + }); + + return ( + + + {machine.pxe_mac} + + {machine.extra_macs && machine.extra_macs.length > 0 ? ( + + {" "} + (+{machine.extra_macs.length}) + + ) : null} + + ) : ( + + + {machine.locked ? ( + + + Locked:{" "} + {" "} + + ) : null} + {machine.hostname} + + .{machine.domain.name} + + ) + } + secondary={ + ipAddresses.length ? ( + <> + + {bootIP || ipAddresses[0]} + + {ipAddresses.length > 1 && ( + + {ipAddresses.length} interfaces: +
    + {ipAddresses.map((address) => ( +
  • {address}
  • + ))} +
+ + } + position="right" + positionElementClassName="p-double-row__tooltip-inner" + > + {ipAddresses.length > 1 ? ( + <> + ( + + ) + + ) : null} +
+ )} + + ) : ( + + ) + } + /> + ); + }, + }, + { + id: "power", + accessorKey: "power", + header: "Power", + cell: ({ row: { original: machine } }: { row: Row }) => { + const powerState = machine.power_state || PowerState.UNKNOWN; + const hasOnAction = machine.actions.includes(NodeActions.ON); + const hasOffAction = machine.actions.includes(NodeActions.OFF); + return ( + + + + } + iconSpace + menuClassName="p-table-menu-hasIcon" + menuLinks={[ + { + children: ( + Turn on + ), + onClick: () => { + dispatch( + machineActions.on({ system_id: machine.system_id }) + ); + }, + disabled: !hasOnAction || powerState === PowerState.ON, + }, + { + children: ( + + Turn off + + ), + onClick: () => { + dispatch( + machineActions.off({ system_id: machine.system_id }) + ); + }, + disabled: !hasOffAction || powerState === PowerState.OFF, + }, + { + children: ( + + Soft power off + + ), + onClick: () => { + dispatch( + machineActions.softOff({ + system_id: machine.system_id, + }) + ); + }, + disabled: + !hasOffAction || + powerState === PowerState.OFF || + machine.power_type !== PowerTypeNames.IPMI, + }, + { + children: ( + <> + + Check power + + ), + onClick: () => { + dispatch(machineActions.checkPower(machine.system_id)); + }, + disabled: powerState === PowerState.UNKNOWN, + }, + ]} + menuTitle="Take action:" + onToggleMenu={toggleMenu} + primary={ +
+ {powerState} +
+ } + primaryTitle={powerState} + secondary={ +
+ {machine.power_type} +
+ } + secondaryTitle={machine.power_type} + /> + ); + }, + }, + { + id: "status", + accessorKey: "status", + header: "Status", + cell: ({ row: { original: machine } }: { row: Row }) => { + const statusText = getStatusText( + machine, + osReleases, + osReleasesLoading + ); + let icon: ReactNode; + if (isTransientStatus(machine.status_code)) { + icon = ; + } else if ( + machine.testing_status === TestStatusStatus.FAILED && + !hideFailedTestWarningStatuses.includes(machine.status_code) + ) { + icon = ( + + ); + } else if (isUnconfiguredPowerType(machine)) { + icon = ( + + ); + } else { + icon = ""; + } + + const progressText = + isTransientStatus(machine.status_code) && machine.status_message + ? machine.status_message + : ""; + + const actions: MachineMenuAction[] = [ + NodeActions.ABORT, + NodeActions.ACQUIRE, + NodeActions.COMMISSION, + NodeActions.DEPLOY, + NodeActions.EXIT_RESCUE_MODE, + NodeActions.LOCK, + NodeActions.MARK_BROKEN, + NodeActions.MARK_FIXED, + NodeActions.OVERRIDE_FAILED_TESTING, + NodeActions.RELEASE, + NodeActions.RESCUE_MODE, + NodeActions.TEST, + NodeActions.UNLOCK, + ]; + + const menuLinks = [ + getMachineActionLinks( + actions, + generalMachineActions, + dispatch, + machine + ), + [ + { + children: "See logs", + element: Link, + to: `/machine/${machine.system_id}/logs`, + }, + ], + ]; + + return ( + + {statusText} + + } + secondary={ + isEphemerallyDeployed(machine) ? ( + Deployed in memory + ) : ( + <> + + {progressText} + + + {machine.error_description && + machine.status_code === NodeStatusCode.BROKEN ? ( + + {machine.error_description} + + ) : ( + "" + )} + + + ) + } + /> + ); + }, + }, + { + id: "owner", + accessorKey: "owner", + meta: { isInteractiveHeader: true }, + header: (header) => { + return ( + <> + +  |  + + {{ + asc: ascending, + desc: descending, + }[header?.column?.getIsSorted() as string] ?? null} +
+ Tags + + ); + }, + cell: ({ row: { original: machine } }: { row: Row }) => { + const actions: MachineMenuAction[] = [ + NodeActions.ACQUIRE, + NodeActions.RELEASE, + ]; + const user = users?.items.find( + (user) => user.username === machine.owner + ); + const ownerDisplay = showFullName + ? user?.last_name || machine?.owner || "-" + : machine?.owner || "-"; + const menuLinks = getMachineActionLinks( + actions, + generalMachineActions, + dispatch, + machine, + "No owner actions available." + ); + const machineTags = getMachineTags(machine, tags); + const tagsDisplay = getTagsDisplay(machineTags); + return ( + {ownerDisplay}} + primaryTitle={ownerDisplay} + secondary={ + + {tagsDisplay} + + } + secondaryTitle={tagsDisplay} + /> + ); + }, + }, + { + id: "pool", + accessorKey: "pool", + header: () => { + return ( + <> + Pool +
+ Note + + ); + }, + cell: ({ row: { original: machine } }: { row: Row }) => { + return ( + + {machine.pool.name} + + } + primaryAriaLabel="Pool" + primaryTitle={machine.pool.name} + secondary={ + + {machine.description} + + } + secondaryAriaLabel="Note" + secondaryTitle={machine.description} + /> + ); + }, + }, + { + id: "zone", + accessorKey: "zone", + header: () => { + return ( + <> + Zone +
+ Spaces + + ); + }, + cell: ({ row: { original: machine } }: { row: Row }) => { + return ( + + {machine.zone.name} + + } + primaryTitle={machine.zone.name} + secondary={getSpaces(machine)} + /> + ); + }, + }, + { + id: "fabric", + accessorKey: "fabric", + // TODO: enable sorting by "fabric" when the API supports it + enableSorting: false, + header: () => { + return ( + <> + Fabric +
+ VLAN + + ); + }, + cell: ({ row: { original: machine } }: { row: Row }) => { + const fabricID = machine.vlan && machine.vlan.fabric_id; + const fabricName = machine.vlan && machine.vlan.fabric_name; + const vlan = + machine.vlan && machine.vlan.name ? machine.vlan.name : ""; + + return ( + + {fabricName && (fabricID || fabricID === 0) ? ( + + {fabricName} + + ) : ( + "-" + )} + + } + primaryAriaLabel="Fabric" + primaryTitle={fabricName} + secondary={{vlan}} + secondaryAriaLabel="VLAN" + secondaryTitle={vlan} + /> + ); + }, + }, + { + id: "cpu", + accessorKey: "cpu_count", + header: () => { + return ( + <> + Cores +
+ Arch + + ); + }, + cell: ({ row: { original: machine } }: { row: Row }) => { + return ( + + {machine.cpu_count} + + } + primaryAriaLabel="Cores" + primaryClassName="u-align--right" + secondary={ + + + {machine.architecture.includes("/generic") + ? machine.architecture.split("/")[0] + : machine.architecture} + + + } + secondaryAriaLabel="Architecture" + /> + ); + }, + }, + { + id: "memory", + accessorKey: "memory", + header: "RAM", + cell: ({ row: { original: machine } }: { row: Row }) => { + return ( + + {machine.memory}  + GiB + + } + primaryClassName="u-align--right" + /> + ); + }, + }, + { + id: "disks", + accessorKey: "physical_disk_count", + header: "Disks", + cell: ({ row: { original: machine } }: { row: Row }) => { + return ( + + {machine.physical_disk_count} + + } + primaryClassName="u-align--right" + /> + ); + }, + }, + { + id: "storage", + accessorKey: "storage", + header: "Storage", + cell: ({ row: { original: machine } }: { row: Row }) => { + const formattedStorage = formatBytes({ + value: machine.storage, + unit: "GB", + }); + return ( + + + {formattedStorage.value} + +   + + {formattedStorage.unit} + + + } + primaryClassName="u-align--right" + /> + ); + }, + }, + ] as MachineColumnDef[], + [ + dispatch, + generalMachineActions, + grouping, + osReleases, + osReleasesLoading, + resourcePools?.items, + showFullName, + showMAC, + tags, + toggleMenu, + users?.items, + zones?.items, + ] + ); +}; + +export default useMachinesTableColumns; diff --git a/src/app/machines/views/MachineList/MachineList.tsx b/src/app/machines/views/MachineList/MachineList.tsx deleted file mode 100644 index cd888fd471..0000000000 --- a/src/app/machines/views/MachineList/MachineList.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useEffect, useState } from "react"; - -import type { ValueOf } from "@canonical/react-components"; -import { useDispatch, useSelector } from "react-redux"; - -import ErrorsNotification from "./ErrorsNotification"; -import MachineListTable from "./MachineListTable"; -import { DEFAULTS } from "./MachineListTable/constants"; -import { usePageSize, type useResponsiveColumns } from "./hooks"; - -import VaultNotification from "@/app/base/components/VaultNotification"; -import { useFetchActions, useWindowTitle } from "@/app/base/hooks"; -import type { SortDirection } from "@/app/base/types"; -import { controllerActions } from "@/app/store/controller"; -import { generalActions } from "@/app/store/general"; -import { machineActions } from "@/app/store/machine"; -import machineSelectors from "@/app/store/machine/selectors"; -import type { FetchGroupKey } from "@/app/store/machine/types"; -import { FilterMachines } from "@/app/store/machine/utils"; -import { useFetchMachines } from "@/app/store/machine/utils/hooks"; - -type Props = { - grouping: FetchGroupKey | null; - hiddenColumns: ReturnType[0]; - hiddenGroups: (string | null)[]; - headerFormOpen?: boolean; - searchFilter: string; - setHiddenGroups: (groups: (string | null)[]) => void; -}; - -const MachineList = ({ - grouping, - hiddenColumns, - hiddenGroups, - headerFormOpen, - searchFilter, - setHiddenGroups, -}: Props): React.ReactElement => { - useWindowTitle("Machines"); - const dispatch = useDispatch(); - const errors = useSelector(machineSelectors.errors); - const [currentPage, setCurrentPage] = useState(1); - const [sortKey, setSortKey] = useState( - DEFAULTS.sortKey - ); - const [sortDirection, setSortDirection] = useState< - ValueOf - >(DEFAULTS.sortDirection); - - const [pageSize, setPageSize] = usePageSize(); - - const { - callId, - groups, - loading, - machineCount, - machines, - machinesErrors, - totalPages, - } = useFetchMachines({ - collapsedGroups: hiddenGroups, - filters: FilterMachines.parseFetchFilters(searchFilter), - grouping, - sortDirection, - sortKey, - pagination: { currentPage, setCurrentPage, pageSize }, - }); - - useEffect( - () => () => { - // Clear machine selected state and clean up any machine errors etc. - // when closing the list. - dispatch(machineActions.setSelected(null)); - dispatch(machineActions.cleanup()); - }, - [dispatch] - ); - - // Fetch vault enabled status and controllers on page load - useFetchActions([controllerActions.fetch, generalActions.fetchVaultEnabled]); - - return ( - <> - {errors && !headerFormOpen ? ( - dispatch(machineActions.cleanup())} - /> - ) : null} - {!headerFormOpen ? : null} - - - - ); -}; - -export default MachineList; diff --git a/src/app/machines/views/MachineList/MachineListTable/CoresColumn/CoresColumn.test.tsx b/src/app/machines/views/MachineList/MachineListTable/CoresColumn/CoresColumn.test.tsx deleted file mode 100644 index 65a6493eb1..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/CoresColumn/CoresColumn.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { CoresColumn } from "./CoresColumn"; - -import type { RootState } from "@/app/store/root/types"; -import * as factory from "@/testing/factories"; -import { - renderWithBrowserRouter, - screen, - userEvent, - waitFor, -} from "@/testing/utils"; - -describe("CoresColumn", () => { - let state: RootState; - - beforeEach(() => { - state = factory.rootState({ - machine: factory.machineState({ - loaded: true, - items: [ - factory.machine({ - system_id: "abc123", - architecture: "amd64/generic", - cpu_count: 4, - cpu_test_status: factory.testStatus({ - status: 1, - }), - }), - ], - }), - }); - }); - - it("displays the number of cores", () => { - state.machine.items[0].cpu_count = 8; - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - expect(screen.getByLabelText("Cores")).toHaveTextContent("8"); - }); - - it("truncates architecture", () => { - state.machine.items[0].architecture = "i386/generic"; - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - expect(screen.getByTestId("arch")).toHaveTextContent("i386"); - }); - - it("displays a Tooltip with the full architecture", async () => { - state.machine.items[0].architecture = "amd64/generic"; - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - - await userEvent.hover(screen.getByTestId("arch")); - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveTextContent("amd64/generic"); - }); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/CoresColumn/CoresColumn.tsx b/src/app/machines/views/MachineList/MachineListTable/CoresColumn/CoresColumn.tsx deleted file mode 100644 index 99ea95a1a9..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/CoresColumn/CoresColumn.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { memo } from "react"; - -import { Tooltip } from "@canonical/react-components"; -import { useSelector } from "react-redux"; - -import MachineTestStatus from "../MachineTestStatus"; - -import DoubleRow from "@/app/base/components/DoubleRow"; -import machineSelectors from "@/app/store/machine/selectors"; -import type { Machine } from "@/app/store/machine/types"; -import type { RootState } from "@/app/store/root/types"; - -type Props = { systemId: Machine["system_id"] }; - -export const CoresColumn = ({ systemId }: Props): React.ReactElement | null => { - const machine = useSelector((state: RootState) => - machineSelectors.getById(state, systemId) - ); - - const formatShortArch = (arch: Machine["architecture"]) => - arch.includes("/generic") ? arch.split("/")[0] : arch; - - if (machine) { - return ( - - {machine.cpu_count} - - } - primaryAriaLabel="Cores" - primaryClassName="u-align--right" - secondary={ - - - {formatShortArch(machine.architecture)} - - - } - secondaryAriaLabel="Architecture" - /> - ); - } - return null; -}; - -export default memo(CoresColumn); diff --git a/src/app/machines/views/MachineList/MachineListTable/CoresColumn/index.tsx b/src/app/machines/views/MachineList/MachineListTable/CoresColumn/index.tsx deleted file mode 100644 index 2d1fe0d5fe..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/CoresColumn/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./CoresColumn"; diff --git a/src/app/machines/views/MachineList/MachineListTable/DisksColumn/DisksColumn.test.tsx b/src/app/machines/views/MachineList/MachineListTable/DisksColumn/DisksColumn.test.tsx deleted file mode 100644 index 2e90e83a12..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/DisksColumn/DisksColumn.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { DisksColumn } from "./DisksColumn"; - -import type { RootState } from "@/app/store/root/types"; -import { TestStatusStatus } from "@/app/store/types/node"; -import * as factory from "@/testing/factories"; -import { - renderWithBrowserRouter, - screen, - userEvent, - waitFor, -} from "@/testing/utils"; - -describe("DisksColumn", () => { - let state: RootState; - beforeEach(() => { - state = factory.rootState({ - machine: factory.machineState({ - loaded: true, - items: [ - factory.machine({ - system_id: "abc123", - physical_disk_count: 1, - storage_test_status: factory.testStatus({ - status: 2, - }), - }), - ], - }), - }); - }); - - it("displays the physical disk count", () => { - state.machine.items[0].physical_disk_count = 2; - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - expect(screen.getByTestId("primary")).toHaveTextContent("2"); - }); - - it("correctly shows error icon and tooltip if storage tests failed", async () => { - state.machine.items[0].storage_test_status = factory.testStatus({ - status: TestStatusStatus.FAILED, - }); - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - - expect(screen.getByLabelText("error")).toHaveClass("p-icon--error"); - - await userEvent.hover(screen.getByRole("button")); - await waitFor(() => { - expect(screen.getByRole("tooltip")).toHaveTextContent( - "Machine has failed tests." - ); - }); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/DisksColumn/DisksColumn.tsx b/src/app/machines/views/MachineList/MachineListTable/DisksColumn/DisksColumn.tsx deleted file mode 100644 index b4507d5d95..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/DisksColumn/DisksColumn.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { memo } from "react"; - -import { useSelector } from "react-redux"; - -import MachineTestStatus from "../MachineTestStatus"; - -import DoubleRow from "@/app/base/components/DoubleRow"; -import machineSelectors from "@/app/store/machine/selectors"; -import type { Machine } from "@/app/store/machine/types"; -import type { RootState } from "@/app/store/root/types"; - -type Props = { systemId: Machine["system_id"] }; - -export const DisksColumn = ({ systemId }: Props): React.ReactElement | null => { - const machine = useSelector((state: RootState) => - machineSelectors.getById(state, systemId) - ); - - if (machine) { - return ( - - {machine.physical_disk_count} - - } - primaryClassName="u-align--right" - /> - ); - } - return null; -}; - -export default memo(DisksColumn); diff --git a/src/app/machines/views/MachineList/MachineListTable/DisksColumn/index.ts b/src/app/machines/views/MachineList/MachineListTable/DisksColumn/index.ts deleted file mode 100644 index b2d735c318..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/DisksColumn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./DisksColumn"; diff --git a/src/app/machines/views/MachineList/MachineListTable/FabricColumn/FabricColumn.test.tsx b/src/app/machines/views/MachineList/MachineListTable/FabricColumn/FabricColumn.test.tsx deleted file mode 100644 index 84c171303a..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/FabricColumn/FabricColumn.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { FabricColumn } from "./FabricColumn"; - -import type { RootState } from "@/app/store/root/types"; -import * as factory from "@/testing/factories"; -import { renderWithBrowserRouter, screen } from "@/testing/utils"; - -describe("FabricColumn", () => { - let state: RootState; - - beforeEach(() => { - state = factory.rootState({ - machine: factory.machineState({ - loaded: true, - items: [ - factory.machine({ - system_id: "abc123", - network_test_status: factory.testStatus({ - status: 1, - }), - vlan: { - id: 1, - name: "Default VLAN", - fabric_id: 0, - fabric_name: "fabric-0", - }, - }), - ], - }), - }); - }); - - it("displays the fabric name", () => { - state.machine.items[0] = factory.machine({ - system_id: "abc123", - network_test_status: factory.testStatus({ - status: 1, - }), - vlan: { - id: 1, - name: "Default VLAN", - fabric_id: 0, - fabric_name: "fabric-2", - }, - }); - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - expect(screen.getByLabelText("Fabric")).toHaveTextContent(/fabric-2/i); - }); - - it("displays '-' with no fabric present", () => { - state.machine.items[0] = factory.machine({ - system_id: "abc123", - network_test_status: factory.testStatus({ - status: 1, - }), - vlan: null, - }); - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - expect(screen.getByLabelText("Fabric")).toHaveTextContent("-"); - }); - - it("displays VLAN name", () => { - state.machine.items[0] = factory.machine({ - system_id: "abc123", - network_test_status: factory.testStatus({ - status: 1, - }), - vlan: { - id: 1, - name: "Wombat", - fabric_id: 0, - fabric_name: "fabric-2", - }, - }); - - renderWithBrowserRouter(, { - route: "/machines", - state, - }); - expect(screen.getByTestId("vlan")).toHaveTextContent(/Wombat/i); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/FabricColumn/FabricColumn.tsx b/src/app/machines/views/MachineList/MachineListTable/FabricColumn/FabricColumn.tsx deleted file mode 100644 index 434cfaf645..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/FabricColumn/FabricColumn.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { memo } from "react"; - -import { useSelector } from "react-redux"; -import { Link } from "react-router"; - -import MachineTestStatus from "../MachineTestStatus"; - -import DoubleRow from "@/app/base/components/DoubleRow"; -import urls from "@/app/base/urls"; -import machineSelectors from "@/app/store/machine/selectors"; -import type { Machine } from "@/app/store/machine/types"; -import type { RootState } from "@/app/store/root/types"; - -type Props = { systemId: Machine["system_id"] }; - -export const FabricColumn = ({ - systemId, -}: Props): React.ReactElement | null => { - const machine = useSelector((state: RootState) => - machineSelectors.getById(state, systemId) - ); - - if (machine) { - const fabricID = machine.vlan && machine.vlan.fabric_id; - const fabricName = machine.vlan && machine.vlan.fabric_name; - const vlan = machine.vlan && machine.vlan.name ? machine.vlan.name : ""; - - return ( - - {fabricName && (fabricID || fabricID === 0) ? ( - - {fabricName} - - ) : ( - "-" - )} - - } - primaryAriaLabel="Fabric" - primaryTitle={fabricName} - secondary={{vlan}} - secondaryAriaLabel="VLAN" - secondaryTitle={vlan} - /> - ); - } - return null; -}; - -export default memo(FabricColumn); diff --git a/src/app/machines/views/MachineList/MachineListTable/FabricColumn/index.ts b/src/app/machines/views/MachineList/MachineListTable/FabricColumn/index.ts deleted file mode 100644 index f5616c945e..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/FabricColumn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./FabricColumn"; diff --git a/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/GroupCheckbox.test.tsx b/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/GroupCheckbox.test.tsx deleted file mode 100644 index b8fd091441..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/GroupCheckbox.test.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import configureStore from "redux-mock-store"; - -import GroupCheckbox from "./GroupCheckbox"; - -import { machineActions } from "@/app/store/machine"; -import { FetchGroupKey } from "@/app/store/machine/types"; -import type { RootState } from "@/app/store/root/types"; -import * as factory from "@/testing/factories"; -import { userEvent, screen, renderWithMockStore } from "@/testing/utils"; - -const mockStore = configureStore(); - -let state: RootState; -const callId = "123456"; -const group = factory.machineStateListGroup({ - count: 2, - name: "admin2", - value: "admin-2", - items: ["machine1", "machine2", "machine3"], -}); -beforeEach(() => { - state = factory.rootState({ - machine: factory.machineState({ - lists: { - [callId]: factory.machineStateList({ - groups: [group], - }), - }, - }), - }); -}); - -it("is disabled if all machines are selected", () => { - state.machine.selected = { - filter: { - owner: "admin", - }, - }; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).toBeDisabled(); -}); - -it("is disabled if there are no machines in the group", () => { - const group = factory.machineStateListGroup({ - count: 0, - name: "admin2", - value: "admin-2", - }); - state.machine.lists[callId].groups = [group]; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).toBeDisabled(); -}); - -it("is not disabled if there are machines in the group", () => { - state.machine.lists[callId].groups = [ - factory.machineStateListGroup({ - count: 1, - name: "admin2", - value: "admin-2", - }), - ]; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).not.toBeDisabled(); -}); - -it("is unchecked if there are no filters, groups or items selected", () => { - state.machine.selected = null; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).not.toBeChecked(); -}); - -it("is checked if all machines are selected", () => { - state.machine.selected = { - filter: { - owner: "admin", - }, - }; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).toBeChecked(); -}); - -it("is checked if the group is selected", () => { - state.machine.selected = { - items: ["machine1", "machine2", "machine3"], - }; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).toBeChecked(); -}); - -it("is partially checked if a machine in the group is selected", () => { - const group = factory.machineStateListGroup({ - count: 2, - items: ["abc123", "def456"], - name: "admin2", - value: "admin-2", - }); - state.machine.lists[callId].groups = [group]; - state.machine.selected = { - items: ["abc123"], - }; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).toBePartiallyChecked(); -}); - -it("is not checked if a selected machine is in another group", () => { - const group = factory.machineStateListGroup({ - count: 2, - items: ["abc123"], - name: "admin2", - value: "admin-2", - }); - state.machine.lists[callId].groups = [ - factory.machineStateListGroup({ - count: 2, - items: ["def456"], - name: "admin1", - }), - group, - ]; - state.machine.selected = { - items: ["def456"], - }; - renderWithMockStore( - , - { - state, - } - ); - expect(screen.getByRole("checkbox")).not.toBeChecked(); -}); - -it("can dispatch an action to select the group", async () => { - const store = mockStore(state); - renderWithMockStore( - , - { - store, - } - ); - - await userEvent.click(screen.getByRole("checkbox")); - - const expected = machineActions.setSelected({ - grouping: FetchGroupKey.AgentName, - items: ["machine1", "machine2", "machine3"], - }); - - expect( - store.getActions().find((action) => action.type === expected.type) - ).toStrictEqual(expected); -}); - -it("does not overwrite selected machines in different groups", async () => { - const group = factory.machineStateListGroup({ - count: 2, - items: ["abc123"], - name: "admin2", - value: "admin-2", - }); - state.machine.lists[callId].groups = [ - factory.machineStateListGroup({ - count: 2, - items: ["def456"], - name: "admin1", - }), - group, - ]; - state.machine.selected = { - items: ["def456"], - }; - - const store = mockStore(state); - renderWithMockStore( - , - { - store, - } - ); - - await userEvent.click(screen.getByRole("checkbox")); - - const expected = machineActions.setSelected({ - grouping: FetchGroupKey.AgentName, - items: ["def456", "abc123"], - }); - - expect( - store.getActions().find((action) => action.type === expected.type) - ).toStrictEqual(expected); -}); - -it("can dispatch an action to unselect the group", async () => { - const group = factory.machineStateListGroup({ - count: 2, - items: ["abc123"], - name: "admin2", - value: "admin-2", - }); - state.machine.lists[callId].groups = [ - factory.machineStateListGroup({ - count: 2, - items: ["def456"], - name: "admin1", - value: "admin-1", - }), - group, - ]; - state.machine.selected = { - items: ["def456", "abc123"], - }; - - const store = mockStore(state); - renderWithMockStore( - , - { - store, - } - ); - - await userEvent.click(screen.getByRole("checkbox")); - - const expected = machineActions.setSelected({ - items: ["def456"], - }); - - expect( - store.getActions().find((action) => action.type === expected.type) - ).toStrictEqual(expected); -}); - -it("can dispatch an action to unselect the group when it's partially selected", async () => { - const group = factory.machineStateListGroup({ - count: 2, - items: ["abc123", "ghi789"], - name: "admin2", - value: "admin-2", - }); - state.machine.lists[callId].groups = [ - factory.machineStateListGroup({ - count: 2, - items: ["def456"], - name: "admin1", - value: "admin-1", - }), - group, - ]; - state.machine.selected = { - items: ["def456", "abc123"], - }; - - const store = mockStore(state); - renderWithMockStore( - , - { - store, - } - ); - - await userEvent.click(screen.getByRole("checkbox")); - - const expected = machineActions.setSelected({ - items: ["def456"], - }); - - expect( - store.getActions().find((action) => action.type === expected.type) - ).toStrictEqual(expected); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/GroupCheckbox.tsx b/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/GroupCheckbox.tsx deleted file mode 100644 index 69f489ab8f..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/GroupCheckbox.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import cloneDeep from "clone-deep"; -import { useSelector } from "react-redux"; - -import TableCheckbox from "@/app/machines/components/TableCheckbox"; -import { Checked } from "@/app/machines/components/TableCheckbox/TableCheckbox"; -import machineSelectors from "@/app/store/machine/selectors"; -import type { - MachineStateListGroup, - FetchGroupKey, -} from "@/app/store/machine/types"; - -type Props = { - callId?: string | null; - group: MachineStateListGroup | null; - grouping: FetchGroupKey | null; - groupName: MachineStateListGroup["name"]; -}; - -const GroupCheckbox = ({ - callId, - group, - grouping, - groupName, -}: Props): React.ReactElement | null => { - const selected = useSelector(machineSelectors.selected); - const allSelected = !!selected && "filter" in selected; - if (!group) { - return null; - } - // Whether this group is currently selected. - const groupSelected = - !!selected && - "items" in selected && - group.items.every((item) => selected.items?.includes(item)); - // Whether some of the machines in the group are selected. - const childrenSelected = - !!selected && - "items" in selected && - !!selected.items?.find((selectedId) => group?.items.includes(selectedId)); - - return ( - {groupName}} - isChecked={ - allSelected || groupSelected - ? Checked.Checked - : childrenSelected - ? Checked.Mixed - : Checked.Unchecked - } - isDisabled={group?.count === 0 || allSelected} - onGenerateSelected={(checked) => { - const newSelected = - !selected || "filter" in selected - ? { items: [] } - : cloneDeep(selected); - newSelected.items = newSelected.items ?? []; - - if ( - checked && - !group.items.every((item) => newSelected.items?.includes(item)) - ) { - // If the checkbox has been checked and the group's visible items are not - // in the list, add them. - newSelected.items = newSelected.items.concat(group.items); - newSelected.grouping = grouping; - } else if ( - !checked && - group.items.some((item) => newSelected.items?.includes(item)) - ) { - // If the checkbox has been unchecked and the group's visible items are - // in the list then remove them. - newSelected.items = newSelected.items.filter( - (selectedItem) => !group.items.includes(selectedItem) - ); - } - - return newSelected; - }} - /> - ); -}; - -export default GroupCheckbox; diff --git a/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/index.ts b/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/index.ts deleted file mode 100644 index a28128d35a..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/GroupCheckbox/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./GroupCheckbox"; diff --git a/src/app/machines/views/MachineList/MachineListTable/GroupColumn/GroupColumn.test.tsx b/src/app/machines/views/MachineList/MachineListTable/GroupColumn/GroupColumn.test.tsx deleted file mode 100644 index 69a642adfc..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/GroupColumn/GroupColumn.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { MockedFunction } from "vitest"; - -import GroupColumn from "./GroupColumn"; - -import { FetchGroupKey } from "@/app/store/machine/types"; -import { useFetchMachineCount } from "@/app/store/machine/utils/hooks"; -import * as factory from "@/testing/factories"; -import { renderWithMockStore, screen, waitFor } from "@/testing/utils"; - -vi.mock("@/app/store/machine/utils/hooks"); - -const mockedUseFetchMachineCount = useFetchMachineCount as MockedFunction< - typeof useFetchMachineCount ->; -mockedUseFetchMachineCount.mockReturnValue({ - machineCountLoading: false, - machineCountLoaded: true, - machineCount: 2, -}); - -it("displays the correct column name and machines count", () => { - const group = factory.machineStateListGroup({ - collapsed: false, - count: 5, - name: "Test Group", - value: "test-group", - }); - renderWithMockStore( - - ); - - expect(screen.getByText(/Test Group/)).toBeInTheDocument(); - expect(screen.getByText(/5 machines/)).toBeInTheDocument(); -}); - -it("displays correct fetched machines count when initial count is null", async () => { - const group = factory.machineStateListGroup({ - collapsed: false, - count: null, - name: "Test Group", - value: "test-group", - }); - - renderWithMockStore( - - ); - - expect(screen.getByText(/Test Group/)).toBeInTheDocument(); - await waitFor(() => { - expect(screen.getByText(/2 machines/)).toBeInTheDocument(); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/GroupColumn/GroupColumn.tsx b/src/app/machines/views/MachineList/MachineListTable/GroupColumn/GroupColumn.tsx deleted file mode 100644 index b282fd44ea..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/GroupColumn/GroupColumn.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { ReactElement } from "react"; - -import { Button } from "@canonical/react-components"; - -import DoubleRow from "@/app/base/components/DoubleRow"; -import GroupCheckbox from "@/app/machines/views/MachineList/MachineListTable/GroupCheckbox"; -import MachineListGroupCount from "@/app/machines/views/MachineList/MachineListTable/MachineListGroupCount"; -import type { GroupRowsProps } from "@/app/machines/views/MachineList/MachineListTable/types"; -import type { MachineStateListGroup } from "@/app/store/machine/types"; - -export enum Label { - HideGroup = "Hide", - ShowGroup = "Show", -} - -const GroupColumn = ({ - group, - hiddenGroups, - setHiddenGroups, - showActions, - callId, - grouping, - filter, -}: Pick< - GroupRowsProps, - "callId" | "filter" | "hiddenGroups" | "setHiddenGroups" | "showActions" -> & { - grouping: NonNullable; - group: MachineStateListGroup; -}): ReactElement => { - const { collapsed, count, name, value } = group; - return ( - <> - - ) : ( - {name} - ) - } - secondary={ - - } - secondaryClassName={ - showActions ? "u-nudge--secondary-row u-align--left" : null - } - /> -
- -
- - ); -}; - -export default GroupColumn; diff --git a/src/app/machines/views/MachineList/MachineListTable/GroupColumn/index.ts b/src/app/machines/views/MachineList/MachineListTable/GroupColumn/index.ts deleted file mode 100644 index 8831ce98d0..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/GroupColumn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, Label } from "./GroupColumn"; diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/MachineListSelectedCount.test.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/MachineListSelectedCount.test.tsx deleted file mode 100644 index 6a528a18f4..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/MachineListSelectedCount.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import configureStore from "redux-mock-store"; - -import MachineListSelectedCount from "./MachineListSelectedCount"; - -import { machineActions } from "@/app/store/machine"; -import type { RootState } from "@/app/store/root/types"; -import { - screen, - renderWithMockStore, - getTestState, - userEvent, -} from "@/testing/utils"; - -const mockStore = configureStore(); - -describe("MachineListSelectedCount", () => { - let state: RootState; - beforeEach(() => { - state = getTestState(); - }); - - it("displays the number of selected machines", () => { - renderWithMockStore( - , - { state } - ); - - expect(screen.getByText(/10 machines selected/i)).toBeInTheDocument(); - }); - - it("displays a button to select all machines", () => { - renderWithMockStore( - , - { state } - ); - - expect(screen.getByRole("button")).toHaveTextContent( - "Select all 20 machines" - ); - }); - - it("displays a button to select all filtered machines", () => { - renderWithMockStore( - , - { state } - ); - - expect(screen.getByRole("button")).toHaveTextContent( - "Select all 20 filtered machines" - ); - }); - - it("displays a button to clear selection if all machines are selected", () => { - renderWithMockStore( - , - { state } - ); - - expect(screen.getByText(/Selected all 20 machines/i)).toBeInTheDocument(); - expect(screen.getByRole("button")).toHaveTextContent("Clear selection"); - }); - - it("dispatches an action to select all machines", async () => { - const store = mockStore(state); - renderWithMockStore( - , - { store } - ); - - await userEvent.click( - screen.getByRole("button", { name: "Select all 20 machines" }) - ); - - const expectedAction = machineActions.setSelected({ filter: {} }); - - expect( - store.getActions().find((action) => action.type === expectedAction.type) - ).toStrictEqual(expectedAction); - }); - - it("dispatches an action to select all filtered machines", async () => { - const store = mockStore(state); - renderWithMockStore( - , - { store } - ); - - await userEvent.click( - screen.getByRole("button", { name: "Select all 20 filtered machines" }) - ); - - const expectedAction = machineActions.setSelected({ - filter: { free_text: ["this-is-a-filter"] }, - }); - - expect( - store.getActions().find((action) => action.type === expectedAction.type) - ).toStrictEqual(expectedAction); - }); - - it("dispatches an action to clear the selection", async () => { - const store = mockStore(state); - renderWithMockStore( - , - { store } - ); - - await userEvent.click( - screen.getByRole("button", { name: "Clear selection" }) - ); - - const expectedAction = machineActions.setSelected(null); - - expect( - store.getActions().find((action) => action.type === expectedAction.type) - ).toStrictEqual(expectedAction); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/MachineListSelectedCount.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/MachineListSelectedCount.tsx deleted file mode 100644 index fdf5b0babc..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/MachineListSelectedCount.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Notification, Button } from "@canonical/react-components"; -import pluralize from "pluralize"; -import { useDispatch } from "react-redux"; - -import { machineActions } from "@/app/store/machine"; -import { FilterMachines } from "@/app/store/machine/utils"; - -type Props = { - filter: string; - machineCount: number | null; - selectedCount: number; -}; - -export const MachineListSelectedCount = ({ - filter, - machineCount, - selectedCount, -}: Props): React.ReactElement => { - const dispatch = useDispatch(); - - return ( - - {machineCount && selectedCount < machineCount ? ( - <> - {selectedCount} {pluralize("machine", selectedCount)} selected.{" "} - - - ) : ( - <> - Selected all {machineCount} - {filter ? " filtered" : ""} machines.{" "} - - - )} - - ); -}; - -export default MachineListSelectedCount; diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/index.ts b/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/index.ts deleted file mode 100644 index c60d4e8340..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListSelectedCount/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./MachineListSelectedCount"; diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.test.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.test.tsx deleted file mode 100644 index dbb745f385..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.test.tsx +++ /dev/null @@ -1,790 +0,0 @@ -import { waitFor } from "@testing-library/react"; - -import { MachineListTable, Label } from "./MachineListTable"; - -import { SortDirection } from "@/app/base/types"; -import { MachineColumns, columnLabels } from "@/app/machines/constants"; -import type { Machine, MachineStateListGroup } from "@/app/store/machine/types"; -import { FetchGroupKey } from "@/app/store/machine/types"; -import type { RootState } from "@/app/store/root/types"; -import { - NodeStatus, - NodeStatusCode, - TestStatusStatus, -} from "@/app/store/types/node"; -import * as factory from "@/testing/factories"; -import { poolsResolvers } from "@/testing/resolvers/pools"; -import { mockUsers, usersResolvers } from "@/testing/resolvers/users"; -import { zoneResolvers } from "@/testing/resolvers/zones"; -import { - userEvent, - screen, - within, - renderWithProviders, - setupMockServer, -} from "@/testing/utils"; - -setupMockServer( - usersResolvers.listUsers.handler(), - poolsResolvers.listPools.handler(), - zoneResolvers.listZones.handler() -); - -const callId = "mocked-nanoid"; - -describe("MachineListTable", () => { - let state: RootState; - let machines: Machine[] = []; - let groups: MachineStateListGroup[] = []; - beforeEach(() => { - machines = [ - factory.machine({ - actions: [], - architecture: "amd64/generic", - cpu_count: 4, - cpu_test_status: factory.testStatus({ - status: TestStatusStatus.RUNNING, - }), - distro_series: "bionic", - domain: factory.modelRef({ - name: "example", - }), - extra_macs: [], - fqdn: "koala.example", - hostname: "koala", - ip_addresses: [], - memory: 8, - memory_test_status: factory.testStatus({ - status: TestStatusStatus.PASSED, - }), - network_test_status: factory.testStatus({ - status: TestStatusStatus.PASSED, - }), - osystem: "ubuntu", - owner: mockUsers.items[0].username, - physical_disk_count: 1, - pool: factory.modelRef(), - pxe_mac: "00:11:22:33:44:55", - spaces: [], - status: NodeStatus.DEPLOYED, - status_code: NodeStatusCode.DEPLOYED, - status_message: "", - storage: 8, - storage_test_status: factory.testStatus({ - status: TestStatusStatus.PASSED, - }), - testing_status: TestStatusStatus.PASSED, - system_id: "abc123", - zone: factory.modelRef(), - }), - factory.machine({ - actions: [], - architecture: "amd64/generic", - cpu_count: 2, - cpu_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - distro_series: "xenial", - domain: factory.modelRef({ - name: "example", - }), - extra_macs: [], - fqdn: "other.example", - hostname: "other", - ip_addresses: [], - memory: 6, - memory_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - network_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - osystem: "ubuntu", - owner: "user", - physical_disk_count: 2, - pool: factory.modelRef(), - pxe_mac: "66:77:88:99:00:11", - spaces: [], - status: NodeStatus.RELEASING, - status_code: NodeStatusCode.RELEASING, - status_message: "", - storage: 16, - storage_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - testing_status: TestStatusStatus.FAILED, - system_id: "def456", - zone: factory.modelRef(), - }), - factory.machine({ - actions: [], - architecture: "amd64/generic", - cpu_count: 2, - cpu_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - distro_series: "xenial", - domain: factory.modelRef({ - name: "example", - }), - extra_macs: [], - fqdn: "other.example", - hostname: "other", - ip_addresses: [], - memory: 6, - memory_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - network_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - osystem: "ubuntu", - owner: "user", - physical_disk_count: 2, - pool: factory.modelRef(), - pxe_mac: "66:77:88:99:00:11", - spaces: [], - status: NodeStatus.RELEASING, - status_code: NodeStatusCode.DEPLOYED, - status_message: "", - storage: 16, - storage_test_status: factory.testStatus({ - status: TestStatusStatus.FAILED, - }), - testing_status: TestStatusStatus.FAILED, - system_id: "ghi789", - zone: factory.modelRef(), - }), - ]; - groups = [ - factory.machineStateListGroup({ - items: [machines[0].system_id, machines[2].system_id], - name: "Deployed", - }), - factory.machineStateListGroup({ - items: [machines[1].system_id], - name: "Releasing", - }), - ]; - state = factory.rootState({ - general: factory.generalState({ - machineActions: factory.machineActionsState({ - data: [], - }), - osInfo: factory.osInfoState({ - data: factory.osInfo({ - osystems: [["ubuntu", "Ubuntu"]], - releases: [["ubuntu/bionic", 'Ubuntu 18.04 LTS "Bionic Beaver"']], - }), - loaded: true, - }), - }), - machine: factory.machineState({ - items: machines, - lists: { - [callId]: factory.machineStateList({ - loading: true, - groups, - }), - }, - }), - }); - }); - - afterEach(() => { - localStorage.clear(); - }); - - it("displays skeleton rows when loading", () => { - renderWithProviders( - , - { state } - ); - expect( - within( - screen.getAllByRole("gridcell", { - name: columnLabels[MachineColumns.FQDN], - })[0] - ).getByText("xxxxxxxxx.xxxx") - ).toBeInTheDocument(); - expect( - screen.getByRole("grid", { - name: Label.Loading, - }) - ).toHaveClass("machine-list--loading"); - }); - - it("displays a message if there are no search results", () => { - groups = []; - state.machine = factory.machineState({ - items: [], - lists: { - [callId]: factory.machineStateList({ - loading: false, - groups, - }), - }, - }); - - renderWithProviders( - , - { state } - ); - expect(screen.getByText(Label.NoResults)).toBeInTheDocument(); - }); - - it("displays a message if there are no machines", () => { - groups = []; - state.machine = factory.machineState({ - items: [], - lists: { - [callId]: factory.machineStateList({ - loading: false, - groups, - }), - }, - }); - - renderWithProviders( - , - { state } - ); - expect(screen.getByText(Label.EmptyList)).toBeInTheDocument(); - }); - - it("includes groups", () => { - renderWithProviders( - , - { state } - ); - - expect( - screen.queryAllByRole("row", { name: /machines group/i }).length - ).toEqual(2); - expect( - screen.getByRole("row", { name: /Deployed machines group/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole("row", { name: /Releasing machines group/i }) - ).toBeInTheDocument(); - }); - - it("does not display a group header if the table is ungrouped", () => { - renderWithProviders( - , - { state } - ); - expect( - screen.queryByRole("row", { name: /machines group/i }) - ).not.toBeInTheDocument(); - }); - - it("can change machines to display PXE MAC instead of FQDN", async () => { - renderWithProviders( - , - { state } - ); - - const firstMachine = machines[0]; - expect( - screen.getByRole("checkbox", { name: /koala*/i }) - ).toBeInTheDocument(); - const tableHeader = screen.getAllByRole("rowgroup")[0]; - // Click the MAC table header - await userEvent.click( - within(tableHeader).getByRole("button", { name: "MAC" }) - ); - const tableBody = screen.getAllByRole("rowgroup")[1]; - expect(within(tableBody).getAllByRole("link")[0]).toHaveTextContent( - firstMachine.pxe_mac! - ); - }); - - it("can change machines to display full owners name instead of username", async () => { - renderWithProviders( - , - { state } - ); - const tableBody = screen.getAllByRole("rowgroup")[1]; - const getFirstRow = () => within(tableBody).getAllByRole("row")[0]; - const getFirstMachineOwner = () => - within( - within(getFirstRow()).getByRole("gridcell", { name: "Owner" }) - ).getByTestId("owner"); - expect(getFirstMachineOwner()).toHaveTextContent( - mockUsers.items[0].username - ); - await userEvent.click( - within(screen.getByRole("columnheader", { name: "Owner" })).getByRole( - "button", - { name: /Name/ } - ) - ); - await waitFor(() => { - expect(getFirstMachineOwner()).toHaveTextContent( - mockUsers.items[0].last_name! - ); - }); - }); - - it("updates sort on header click", async () => { - const setSortDirection = vi.fn(); - const setSortKey = vi.fn(); - renderWithProviders( - , - { state } - ); - const tableHeader = screen.getAllByRole("rowgroup")[0]; - await userEvent.click( - within(tableHeader).getByRole("button", { name: /cores/i }) - ); - expect(setSortKey).toHaveBeenCalledWith(FetchGroupKey.CpuCount); - expect(setSortDirection).toHaveBeenCalledWith(SortDirection.DESCENDING); - }); - - it("clears the sort when the same header is clicked and is ascending", async () => { - const setSortDirection = vi.fn(); - const setSortKey = vi.fn(); - renderWithProviders( - , - { state } - ); - const tableHeader = screen.getAllByRole("rowgroup")[0]; - await userEvent.click( - within(tableHeader).getByRole("button", { name: /cores/i }) - ); - expect(setSortKey).toHaveBeenCalledWith(null); - expect(setSortDirection).toHaveBeenCalledWith(SortDirection.NONE); - }); - - it("updates the sort when the same header is clicked and is descending", async () => { - const setSortDirection = vi.fn(); - const setSortKey = vi.fn(); - renderWithProviders( - , - { state } - ); - const tableHeader = screen.getAllByRole("rowgroup")[0]; - await userEvent.click( - within(tableHeader).getByRole("button", { name: /cores/i }) - ); - expect(setSortKey).not.toHaveBeenCalled(); - expect(setSortDirection).toHaveBeenCalledWith(SortDirection.ASCENDING); - }); - - it("updates the sort when the same header is clicked and direction is not set", async () => { - const setSortDirection = vi.fn(); - const setSortKey = vi.fn(); - renderWithProviders( - , - { state } - ); - const tableHeader = screen.getAllByRole("rowgroup")[0]; - await userEvent.click( - within(tableHeader).getByRole("button", { name: /cores/i }) - ); - expect(setSortKey).not.toHaveBeenCalled(); - expect(setSortDirection).toHaveBeenCalledWith(SortDirection.ASCENDING); - }); - - it("updates the sort when a different header is clicked", async () => { - const setSortDirection = vi.fn(); - const setSortKey = vi.fn(); - renderWithProviders( - , - { state } - ); - const tableHeader = screen.getAllByRole("rowgroup")[0]; - await userEvent.click( - within(tableHeader).getByRole("button", { name: /power/i }) - ); - expect(setSortKey).toHaveBeenCalledWith(FetchGroupKey.PowerState); - expect(setSortDirection).toHaveBeenCalledWith(SortDirection.DESCENDING); - }); - - it("displays correct selected string in group header", () => { - machines[1].status_code = NodeStatusCode.DEPLOYED; - renderWithProviders( - , - { state } - ); - expect( - within( - screen.getAllByRole("row", { name: /machines group/i })[0] - ).getByText("15 machines") - ).toBeInTheDocument(); - }); - - it("shows the correct number of checkboxes", () => { - renderWithProviders( - , - { state } - ); - expect(screen.getAllByRole("checkbox").length).toBe(4); - }); - - it("does not show checkboxes if showActions is false", () => { - renderWithProviders( - - ); - expect(screen.queryAllByRole("checkbox").length).toBe(0); - }); - - describe("hiddenColumns", () => { - it("can render columns", () => { - renderWithProviders( - , - { state } - ); - expect( - screen.getByRole("columnheader", { name: /power/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole("columnheader", { name: /zone/i }) - ).toBeInTheDocument(); - }); - - it("can hide columns", () => { - renderWithProviders( - , - { state } - ); - - expect( - screen.queryByRole("columnheader", { name: /power/i }) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("columnheader", { name: /zone/i }) - ).not.toBeInTheDocument(); - }); - - it("still displays fqdn if showActions is true", () => { - renderWithProviders( - , - { state } - ); - - expect( - screen.getByRole("columnheader", { name: /FQDN/i }) - ).toBeInTheDocument(); - }); - - it("hides fqdn if if showActions is false", () => { - renderWithProviders( - , - { state } - ); - expect( - screen.queryByRole("columnheader", { name: /FQDN/i }) - ).not.toBeInTheDocument(); - }); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx b/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx deleted file mode 100644 index 72ac9fd5c8..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/MachineListTable.tsx +++ /dev/null @@ -1,490 +0,0 @@ -import { useMemo, memo, useCallback, useState } from "react"; - -import { MainTable } from "@canonical/react-components"; -import classNames from "classnames"; - -import AllCheckbox from "./AllCheckbox"; -import MachineListPagination from "./MachineListPagination"; -import MachineListSelectedCount from "./MachineListSelectedCount/MachineListSelectedCount"; -import PageSizeSelect from "./PageSizeSelect"; -import { - filterColumns, - generateSkeletonRows, - generateGroupRows, -} from "./tableModels"; -import type { MachineListTableProps } from "./types"; - -import ListDisplayCount from "@/app/base/components/ListDisplayCount"; -import TableHeader from "@/app/base/components/TableHeader"; -import { useFetchActions, useSendAnalytics } from "@/app/base/hooks"; -import { SortDirection } from "@/app/base/types"; -import { - columnLabels, - columns, - MachineColumns, - groupOptions, -} from "@/app/machines/constants"; -import { generalActions } from "@/app/store/general"; -import { FetchGroupKey } from "@/app/store/machine/types"; -import { FilterMachines } from "@/app/store/machine/utils"; -import { useMachineSelectedCount } from "@/app/store/machine/utils/hooks"; -import { tagActions } from "@/app/store/tag"; -import { generateEmptyStateMsg, getTableStatus } from "@/app/utils"; - -export enum Label { - EmptyList = "No machines available.", - Loading = "Loading machines", - Machines = "Machines", - NoResults = "No machines match the search criteria.", -} - -export const MachineListTable = ({ - callId, - currentPage, - totalPages, - filter = "", - groups, - grouping, - hiddenColumns = [], - hiddenGroups = [], - machineCount, - machines, - machinesLoading, - pageSize, - setCurrentPage, - setHiddenGroups, - setPageSize, - showActions = true, - sortDirection, - sortKey, - setSortDirection, - setSortKey, - ...props -}: MachineListTableProps): React.ReactElement => { - const parsedFilter = useMemo( - () => FilterMachines.parseFetchFilters(filter), - [filter] - ); - - const sendAnalytics = useSendAnalytics(); - const { selectedCount } = useMachineSelectedCount(parsedFilter); - - const currentSort = { - direction: sortDirection, - key: sortKey, - }; - const updateSort = (newSortKey: MachineListTableProps["sortKey"]) => { - if (newSortKey === sortKey) { - if (sortDirection === SortDirection.ASCENDING) { - setSortKey(null); - setSortDirection(SortDirection.NONE); - } else { - setSortDirection(SortDirection.ASCENDING); - } - } else { - setSortKey(newSortKey); - setSortDirection(SortDirection.DESCENDING); - } - }; - const [showMAC, setShowMAC] = useState(false); - const [showFullName, setShowFullName] = useState(false); - useFetchActions([ - generalActions.fetchArchitectures, - generalActions.fetchDefaultMinHweKernel, - generalActions.fetchHweKernels, - generalActions.fetchMachineActions, - generalActions.fetchOsInfo, - generalActions.fetchPowerTypes, - generalActions.fetchVersion, - tagActions.fetch, - ]); - - const toggleHandler = useCallback( - (columnName: string, open: boolean) => { - if (open) { - sendAnalytics( - "Machine list", - "Inline actions open", - `${columnName} column` - ); - } else if (!open) { - sendAnalytics( - "Machine list", - "Inline actions close", - `${columnName} column` - ); - } - }, - [sendAnalytics] - ); - const getToggleHandler = (columnName: string) => (open: boolean) => { - toggleHandler(columnName, open); - }; - - const rowProps = { - callId, - getToggleHandler, - showActions, - showMAC, - showFullName, - }; - - const headers = [ - { - "aria-label": columnLabels[MachineColumns.FQDN], - key: MachineColumns.FQDN, - className: "fqdn-col", - content: ( -
- {showActions && ( - - )} -
- { - setShowMAC(false); - updateSort(FetchGroupKey.Hostname); - }} - sortKey={FetchGroupKey.Hostname} - > - {columnLabels[MachineColumns.FQDN]} - -  |  - { - setShowMAC(true); - }} - > - MAC - - IP -
-
- ), - }, - { - "aria-label": columnLabels[MachineColumns.POWER], - key: MachineColumns.POWER, - className: "power-col", - content: ( - { - updateSort(FetchGroupKey.PowerState); - }} - sortKey={FetchGroupKey.PowerState} - > - {columnLabels[MachineColumns.POWER]} - - ), - }, - { - "aria-label": columnLabels[MachineColumns.STATUS], - key: MachineColumns.STATUS, - className: "status-col", - content: ( - { - updateSort(FetchGroupKey.Status); - }} - sortKey={FetchGroupKey.Status} - > - {columnLabels[MachineColumns.STATUS]} - - ), - }, - { - "aria-label": columnLabels[MachineColumns.OWNER], - key: MachineColumns.OWNER, - className: "owner-col", - content: ( - <> - { - setShowFullName(false); - updateSort(FetchGroupKey.Owner); - }} - sortKey={FetchGroupKey.Owner} - > - {columnLabels[MachineColumns.OWNER]} - -  |  - { - sendAnalytics( - "Machine list", - "Column header", - "Show owner full name" - ); - setShowFullName(true); - }} - > - Name - - Tags - - ), - }, - { - "aria-label": columnLabels[MachineColumns.POOL], - key: MachineColumns.POOL, - className: "pool-col", - content: ( - <> - { - updateSort(FetchGroupKey.Pool); - }} - sortKey={FetchGroupKey.Pool} - > - {columnLabels[MachineColumns.POOL]} - - Note - - ), - }, - { - "aria-label": columnLabels[MachineColumns.ZONE], - key: MachineColumns.ZONE, - className: "zone-col", - content: ( - <> - { - updateSort(FetchGroupKey.Zone); - }} - sortKey={FetchGroupKey.Zone} - > - {columnLabels[MachineColumns.ZONE]} - - Spaces - - ), - }, - { - "aria-label": columnLabels[MachineColumns.FABRIC], - key: MachineColumns.FABRIC, - className: "fabric-col", - content: ( - <> - - {columnLabels[MachineColumns.FABRIC]} - - VLAN - - ), - }, - { - "aria-label": columnLabels[MachineColumns.CPU], - key: MachineColumns.CPU, - className: "cores-col u-align--right", - content: ( - <> - { - updateSort(FetchGroupKey.CpuCount); - }} - sortKey={FetchGroupKey.CpuCount} - > - {columnLabels[MachineColumns.CPU]} - - Arch - - ), - }, - { - "aria-label": columnLabels[MachineColumns.MEMORY], - key: MachineColumns.MEMORY, - className: "ram-col u-align--right", - content: ( - { - updateSort(FetchGroupKey.Memory); - }} - sortKey={FetchGroupKey.Memory} - > - {columnLabels[MachineColumns.MEMORY]} - - ), - }, - { - "aria-label": columnLabels[MachineColumns.DISKS], - key: MachineColumns.DISKS, - className: "disks-col u-align--right", - content: ( - { - updateSort(FetchGroupKey.PhysicalDiskCount); - }} - sortKey={FetchGroupKey.PhysicalDiskCount} - > - {columnLabels[MachineColumns.DISKS]} - - ), - }, - { - "aria-label": columnLabels[MachineColumns.STORAGE], - key: MachineColumns.STORAGE, - className: "storage-col u-align--right", - content: ( - { - updateSort(FetchGroupKey.TotalStorage); - }} - sortKey={FetchGroupKey.TotalStorage} - > - {columnLabels[MachineColumns.STORAGE]} - - ), - }, - ]; - - const rows = generateGroupRows({ - grouping, - groups, - hiddenGroups, - machines, - setHiddenGroups, - hiddenColumns, - filter: parsedFilter, - ...rowProps, - }); - - const skeletonRows = useMemo( - () => generateSkeletonRows(hiddenColumns, showActions), - [hiddenColumns, showActions] - ); - - const selectionState = useMemo(() => { - if (selectedCount > 0) { - return [ - { - className: "select-notification", - key: "select-info", - columns: [ - { - colSpan: columns.length - hiddenColumns.length, - content: ( - - ), - }, - ], - }, - ]; - } else { - return []; - } - }, [filter, hiddenColumns.length, machineCount, selectedCount]); - - const machineRows = useMemo( - () => [...selectionState, ...rows], - [rows, selectionState] - ); - - const groupByStatus = useMemo( - () => groupOptions.find(({ value }) => value === grouping)?.label ?? "", - [grouping] - ); - - const tableStatus = getTableStatus({ - isLoading: !!machinesLoading, - hasFilter: !!filter, - }); - - return ( - <> - {machineCount ? ( -
-
- - - - {setPageSize ? ( - - ) : null} - -
- ) : null} -
- - - ); -}; - -export default memo(MachineListTable); diff --git a/src/app/machines/views/MachineList/MachineListTable/NameColumn/NameColumn.test.tsx b/src/app/machines/views/MachineList/MachineListTable/NameColumn/NameColumn.test.tsx deleted file mode 100644 index 316dcfb0b0..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/NameColumn/NameColumn.test.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { NameColumn } from "./NameColumn"; - -import type { RootState } from "@/app/store/root/types"; -import { NodeStatus } from "@/app/store/types/node"; -import * as factory from "@/testing/factories"; -import { - renderWithBrowserRouter, - screen, - userEvent, - waitFor, -} from "@/testing/utils"; - -describe("NameColumn", () => { - let state: RootState; - beforeEach(() => { - state = factory.rootState({ - machine: factory.machineState({ - loaded: true, - items: [ - factory.machine({ - domain: factory.modelRef({ - name: "example", - }), - extra_macs: [], - fqdn: "koala.example", - hostname: "koala", - ip_addresses: [], - pool: factory.modelRef(), - pxe_mac: "00:11:22:33:44:55", - status: NodeStatus.RELEASING, - system_id: "abc123", - zone: factory.modelRef(), - }), - ], - }), - }); - }); - - it("can be locked", () => { - state.machine.items[0].locked = true; - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByLabelText("Locked")).toHaveClass("p-icon--locked"); - }); - - it("can show the FQDN", () => { - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByRole("link", { name: /koala*/i })).toBeInTheDocument(); - }); - - it("can show a single ip address", () => { - state.machine.items[0].ip_addresses = [{ ip: "127.0.0.1", is_boot: false }]; - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByTestId("ip-addresses")).toHaveTextContent("127.0.0.1"); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); - }); - - it("can show multiple ip addresses", async () => { - state.machine.items[0].ip_addresses = [ - { ip: "127.0.0.1", is_boot: false }, - { ip: "127.0.0.2", is_boot: false }, - ]; - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByTestId("ip-addresses")).toHaveTextContent("127.0.0.1"); - const button = screen.getByRole("button", { name: "+1" }); - expect(button).toBeInTheDocument(); - - await userEvent.hover(button); - await waitFor(() => { - expect(screen.getByRole("tooltip")).toBeInTheDocument(); - }); - }); - - it("can show a PXE ip address", () => { - state.machine.items[0].ip_addresses = [{ is_boot: true, ip: "127.0.0.1" }]; - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByTestId("ip-addresses")).toHaveTextContent( - "127.0.0.1 (PXE)" - ); - }); - - it("doesn't show duplicate ip addresses", () => { - state.machine.items[0].ip_addresses = [ - { ip: "127.0.0.1", is_boot: false }, - { ip: "127.0.0.1", is_boot: false }, - ]; - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByTestId("ip-addresses")).toHaveTextContent("127.0.0.1"); - expect( - screen.queryByRole("button", { name: "+1" }) - ).not.toBeInTheDocument(); - expect(screen.queryByTestId("Tooltip")).not.toBeInTheDocument(); - }); - - it("can show a single mac address", () => { - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByRole("link")).toHaveTextContent("00:11:22:33:44:55"); - }); - - it("can show multiple mac address", () => { - state.machine.items[0].extra_macs = ["aa:bb:cc:dd:ee:ff"]; - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getAllByRole("link")).toHaveLength(2); - expect(screen.getAllByRole("link")[1]).toHaveTextContent(/\(\+1\)/); - }); - - it("can render a machine with minimal data", () => { - state.machine.items[0] = factory.machine({ - domain: factory.modelRef({ - name: "example", - }), - fqdn: "koala.example", - hostname: "koala", - system_id: "abc123", - }); - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.getByRole("link", { name: /koala*/i })).toBeInTheDocument(); - }); - - it("can render a machine in the MAC state with minimal data", () => { - state.machine.items[0] = factory.machine({ - domain: factory.modelRef({ - name: "example", - }), - hostname: "koala", - pxe_mac: "00:11:22:33:44:55", - system_id: "abc123", - }); - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - - expect( - screen.getByText(`${state.machine.items[0].pxe_mac}`) - ).toBeInTheDocument(); - }); - - it("does not render checkbox if onToggleMenu not provided", () => { - renderWithBrowserRouter( - , - { route: "/machines", state } - ); - expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/NameColumn/NameColumn.tsx b/src/app/machines/views/MachineList/MachineListTable/NameColumn/NameColumn.tsx deleted file mode 100644 index ae0a1b4888..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/NameColumn/NameColumn.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { memo } from "react"; - -import { Button, Tooltip } from "@canonical/react-components"; -import classNames from "classnames"; -import { useSelector } from "react-redux"; -import { Link } from "react-router"; - -import MachineCheckbox from "../MachineCheckbox"; - -import DoubleRow from "@/app/base/components/DoubleRow"; -import MacAddressDisplay from "@/app/base/components/MacAddressDisplay"; -import NonBreakingSpace from "@/app/base/components/NonBreakingSpace"; -import urls from "@/app/base/urls"; -import machineSelectors from "@/app/store/machine/selectors"; -import type { - Machine, - MachineMeta, - MachineStateListGroup, -} from "@/app/store/machine/types"; -import type { RootState } from "@/app/store/root/types"; - -type Props = { - callId?: string | null; - groupValue: MachineStateListGroup["value"]; - showActions?: boolean; - showMAC?: boolean; - systemId: Machine[MachineMeta.PK]; - machines?: Machine[]; -}; - -const generateFQDN = (machine: Machine, machineURL: string) => { - return ( - - - {machine.locked ? ( - - - Locked:{" "} - {" "} - - ) : null} - {machine.hostname} - - .{machine.domain.name} - - ); -}; - -const generateIPAddresses = (machine: Machine) => { - const ipAddresses: string[] = []; - let bootIP; - - (machine.ip_addresses || []).forEach((address) => { - let ip = address.ip; - if (address.is_boot) { - ip = `${ip} (PXE)`; - bootIP = ip; - } - if (!ipAddresses.includes(ip)) { - ipAddresses.push(ip); - } - }); - - if (ipAddresses.length) { - const ipAddressesLine = ( - <> - - {bootIP || ipAddresses[0]} - - - ); - - if (ipAddresses.length === 1) { - return ipAddressesLine; - } - return ( - <> - {ipAddressesLine} - - {ipAddresses.length} interfaces: -
    - {ipAddresses.map((address) => ( -
  • {address}
  • - ))} -
- - } - position="right" - positionElementClassName="p-double-row__tooltip-inner" - > - {ipAddresses.length > 1 ? ( - <> - ( - - ) - - ) : null} -
- - ); - } - return ""; -}; - -const generateMAC = (machine: Machine, machineURL: string) => { - return ( - <> - - {machine.pxe_mac} - - {machine.extra_macs && machine.extra_macs.length > 0 ? ( - (+{machine.extra_macs.length}) - ) : null} - - ); -}; - -export const NameColumn = ({ - callId, - groupValue, - showActions, - showMAC, - systemId, - machines, -}: Props): React.ReactElement | null => { - const machine = useSelector((state: RootState) => - machineSelectors.getById(state, systemId) - ); - if (!machine) { - return null; - } - const machineURL = urls.machines.machine.index({ id: machine.system_id }); - const primaryRow = showMAC - ? generateMAC(machine, machineURL) - : generateFQDN(machine, machineURL); - const secondaryRow = !showMAC && generateIPAddresses(machine); - - return ( - - ) : ( - primaryRow - ) - } - // fallback to non-breaking space to keep equal height of all rows - secondary={secondaryRow || } - secondaryClassName={classNames([ - "u-flex", - { "u-nudge--secondary-row u-align--left": showActions }, - ])} - /> - ); -}; - -export default memo(NameColumn); diff --git a/src/app/machines/views/MachineList/MachineListTable/NameColumn/index.ts b/src/app/machines/views/MachineList/MachineListTable/NameColumn/index.ts deleted file mode 100644 index 6a2c5e9a2d..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/NameColumn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./NameColumn"; diff --git a/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/OwnerColumn.test.tsx b/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/OwnerColumn.test.tsx deleted file mode 100644 index 637335091b..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/OwnerColumn.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { waitFor } from "@testing-library/react"; - -import { OwnerColumn } from "./OwnerColumn"; - -import type { RootState } from "@/app/store/root/types"; -import { NodeActions } from "@/app/store/types/node"; -import * as factory from "@/testing/factories"; -import { authResolvers } from "@/testing/resolvers/auth"; -import { mockUsers, usersResolvers } from "@/testing/resolvers/users"; -import { - renderWithBrowserRouter, - screen, - setupMockServer, - userEvent, -} from "@/testing/utils"; - -const mockServer = setupMockServer( - authResolvers.getCurrentUser.handler(), - usersResolvers.listUsers.handler() -); - -describe("OwnerColumn", () => { - let state: RootState; - beforeEach(() => { - state = factory.rootState({ - general: factory.generalState({ - machineActions: factory.machineActionsState({ - data: [ - factory.machineAction({ - name: NodeActions.ACQUIRE, - title: "Allocate...", - }), - factory.machineAction({ - name: NodeActions.RELEASE, - title: "Release...", - }), - ], - }), - }), - machine: factory.machineState({ - loaded: true, - items: [ - factory.machine({ - actions: [], - system_id: "abc123", - owner: "user1", - tags: [], - }), - ], - }), - }); - }); - - it("displays owner's username", () => { - renderWithBrowserRouter( - , - { state, route: "/machines" } - ); - - expect(screen.getByTestId("owner")).toHaveTextContent("user1"); - }); - - it("displays owner's username if showFullName is true and user doesn't have a full name", () => { - mockServer.use( - authResolvers.getCurrentUser.handler( - factory.user({ last_name: "", username: "user1" }) - ) - ); - renderWithBrowserRouter( - , - { state, route: "/machines" } - ); - - expect(screen.getByTestId("owner")).toHaveTextContent("user1"); - }); - - it("can display owner's full name if present", async () => { - renderWithBrowserRouter( - , - { state, route: "/machines" } - ); - - await waitFor(() => { - expect(screen.getByTestId("owner")).toHaveTextContent( - mockUsers.items[0].last_name! - ); - }); - }); - - it("displays tags", () => { - state.machine.items[0].tags = [1, 2]; - state.tag.items = [ - factory.tag({ id: 1, name: "minty" }), - factory.tag({ id: 2, name: "aloof" }), - ]; - renderWithBrowserRouter( - , - { state, route: "/machines" } - ); - - expect(screen.getByTestId("tags")).toHaveTextContent("aloof, minty"); - }); - - it("can show a menu item to allocate a machine", async () => { - state.machine.items[0].actions = [NodeActions.ACQUIRE]; - renderWithBrowserRouter( - , - { state, route: "/machines" } - ); - // Open the menu so the elements get rendered. - await userEvent.click(screen.getByRole("button", { name: "Take action:" })); - - expect( - screen.getByRole("button", { name: "Allocate..." }) - ).toBeInTheDocument(); - }); - - it("can show a menu item to release a machine", async () => { - state.machine.items[0].actions = [NodeActions.RELEASE]; - renderWithBrowserRouter( - , - { state, route: "/machines" } - ); - // Open the menu so the elements get rendered. - await userEvent.click(screen.getByRole("button", { name: "Take action:" })); - - expect( - screen.getByRole("button", { name: "Release..." }) - ).toBeInTheDocument(); - }); - - it("can show a message when there are no menu items", async () => { - renderWithBrowserRouter( - , - { state, route: "/machines" } - ); - // Open the menu so the elements get rendered. - await userEvent.click(screen.getByRole("button", { name: "Take action:" })); - - expect(screen.getByText("No owner actions available")).toBeInTheDocument(); - }); - - it("does not render table menu if onToggleMenu not provided", () => { - renderWithBrowserRouter(, { - state, - route: "/machines", - }); - expect( - screen.queryByRole("button", { name: "Take action:" }) - ).not.toBeInTheDocument(); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/OwnerColumn.tsx b/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/OwnerColumn.tsx deleted file mode 100644 index bcb486e896..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/OwnerColumn.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { memo, useCallback, useEffect, useMemo, useState } from "react"; - -import { Spinner } from "@canonical/react-components"; -import { useSelector } from "react-redux"; - -import { useUsers } from "@/app/api/query/users"; -import DoubleRow from "@/app/base/components/DoubleRow"; -import { useMachineActions } from "@/app/base/hooks"; -import type { MachineMenuAction } from "@/app/base/hooks/node"; -import { useToggleMenu } from "@/app/machines/hooks"; -import type { MachineMenuToggleHandler } from "@/app/machines/types"; -import machineSelectors from "@/app/store/machine/selectors"; -import type { Machine, MachineMeta } from "@/app/store/machine/types"; -import type { RootState } from "@/app/store/root/types"; -import tagSelectors from "@/app/store/tag/selectors"; -import { getTagsDisplay } from "@/app/store/tag/utils"; -import { NodeActions } from "@/app/store/types/node"; - -type Props = { - onToggleMenu?: MachineMenuToggleHandler; - systemId: Machine[MachineMeta.PK]; - showFullName?: boolean; -}; - -const actions: MachineMenuAction[] = [NodeActions.ACQUIRE, NodeActions.RELEASE]; - -export const OwnerColumn = ({ - onToggleMenu, - systemId, - showFullName, -}: Props): React.ReactElement => { - const [updating, setUpdating] = useState(null); - const machine = useSelector((state: RootState) => - machineSelectors.getById(state, systemId) - ); - const machineTags = useSelector((state: RootState) => - tagSelectors.getByIDs(state, machine?.tags || null) - ); - const toggleMenu = useToggleMenu(onToggleMenu || null); - const user = - useUsers({ query: { username_or_email: machine?.owner || "" } }).data - ?.items[0] || null; - const ownerDisplay = showFullName - ? user?.last_name || machine?.owner || "-" - : machine?.owner || "-"; - const tagsDisplay = getTagsDisplay(machineTags); - - const handleMachineActionClick = useCallback(() => { - if (machine) { - setUpdating(machine.status); - } - }, [machine]); - - const menuLinks = useMachineActions( - systemId, - actions, - "No owner actions available", - handleMachineActionClick - ); - - useEffect(() => { - if (updating !== null && machine?.status !== updating) { - setUpdating(null); - } - }, [updating, machine?.status]); - - const primary = useMemo( - () => ( - <> - {updating === null ? null : } - {ownerDisplay} - - ), - [updating, ownerDisplay] - ); - const secondary = useMemo( - () => ( - - {tagsDisplay} - - ), - [tagsDisplay] - ); - - return ( - - ); -}; - -export default memo(OwnerColumn); diff --git a/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/index.ts b/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/index.ts deleted file mode 100644 index e306451fbe..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/OwnerColumn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./OwnerColumn"; diff --git a/src/app/machines/views/MachineList/MachineListTable/PageSizeSelect/PageSizeSelect.test.tsx b/src/app/machines/views/MachineList/MachineListTable/PageSizeSelect/PageSizeSelect.test.tsx deleted file mode 100644 index 9e74fbbb5f..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/PageSizeSelect/PageSizeSelect.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { DEFAULTS } from "../constants"; - -import PageSizeSelect from "./PageSizeSelect"; - -import { render, screen, userEvent } from "@/testing/utils"; - -const DEFAULT_PAGE_SIZE = DEFAULTS.pageSize; - -describe("PageSizeSelect", () => { - it("renders", () => { - render( - - ); - expect( - screen.getByRole("combobox", { name: "Items per page" }) - ).toBeInTheDocument(); - }); - - it("calls a function to update the page size and reset to the first page", async () => { - const setPageSize = vi.fn(); - const setCurrentPage = vi.fn(); - - render( - - ); - - const pageSizeSelect = screen.getByRole("combobox", { - name: "Items per page", - }); - await userEvent.selectOptions(pageSizeSelect, "100"); - - expect(setPageSize).toHaveBeenCalledWith(100); - expect(setCurrentPage).toHaveBeenCalledWith(1); - }); -}); diff --git a/src/app/machines/views/MachineList/MachineListTable/PageSizeSelect/PageSizeSelect.tsx b/src/app/machines/views/MachineList/MachineListTable/PageSizeSelect/PageSizeSelect.tsx deleted file mode 100644 index ae93b2e529..0000000000 --- a/src/app/machines/views/MachineList/MachineListTable/PageSizeSelect/PageSizeSelect.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Select } from "@canonical/react-components"; -import type { PaginationProps } from "@canonical/react-components"; - -type Props = { - pageSize: number; - paginate: NonNullable; - setPageSize: (pageSize: number) => void; -}; - -export enum Labels { - ItemsPerPage = "Items per page", - Fifty = "50/page", - OneHundred = "100/page", - TwoHundred = "200/page", -} - -const groupOptions = [ - { - value: 50, - label: Labels.Fifty, - }, - { - value: 100, - label: Labels.OneHundred, - }, - { - value: 200, - label: Labels.TwoHundred, - }, -]; - -const PageSizeSelect = ({ - pageSize, - paginate, - setPageSize, -}: Props): React.ReactElement => { - return ( -