Repository: canonical/maas-ui
Pull request #5668: chore: refactor pools and SSH keys to best practices
Author: login: rumanowskak
Labels: name: Review: Code +1
name: Review: QA +1
Created: 2025-03-26T20:04:03Z | Merged: 2025-03-27T12:39:10Z

## Done
- Generic Table is implemented instead of Main Table
- added PoolsTable and PoolsTable.test files
- added usePoolsTableColumns and usePoolsTableColumns.test.files
- moved correctly SSHKeys files from base components to preferences SSHKeys

<!--
- Itemised list of what was changed by this PR.
-->

## QA steps

- [x] go to pools and check if GenericTable is being used instead of MainTable component

<!-- Steps for QA. -->

## Fixes

Fixes: [MAASENG-4551](https://warthogs.atlassian.net/jira/software/c/projects/MAASENG/boards/1423?selectedIssue=MAASENG-4551)

<!-- If there's an existing JIRA/launchpad issue/bug for your change, please link to it above. -->

## Screenshots
<img width="1512" alt="Screenshot 2025-03-26 at 21 03 19" src="https://github.com/user-attachments/assets/c9ff8639-5eba-4595-b51c-4da5dafa4e2a" />

<!--
Attach any screenshots or videos that help illustrate or demonstrate the changes made in this PR.
-->

## Notes

<!--
(Optional)
Leave any additional notes for the reviewer here.
-->
[MAASENG-4551](https://warthogs.atlassian.net/jira/software/c/projects/MAASENG/boards/1423?selectedIssue=MAASENG-4551)

[MAASENG-4551]: https://warthogs.atlassian.net/browse/MAASENG-4551?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
[MAASENG-4551]: https://warthogs.atlassian.net/browse/MAASENG-4551?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ


Diff:
diff --git a/src/app/base/components/ResourcePoolSelect/ResourcePoolSelect.test.tsx b/src/app/base/components/ResourcePoolSelect/ResourcePoolSelect.test.tsx
index 4207eaf227..e6d00b5a81 100644
--- a/src/app/base/components/ResourcePoolSelect/ResourcePoolSelect.test.tsx
+++ b/src/app/base/components/ResourcePoolSelect/ResourcePoolSelect.test.tsx
@@ -22,9 +22,13 @@ describe("ResourcePoolSelect", () => {
 
     await waitFor(() => expect(poolsResolvers.listPools.resolved).toBeTruthy());
 
+    await waitFor(() =>
+      expect(
+        screen.getByRole("option", { name: "swimming" })
+      ).toBeInTheDocument()
+    );
     const pools = screen.getAllByRole("option");
     expect(pools).toHaveLength(mockPools.items.length + 1);
-    expect(pools[1]).toHaveTextContent("swimming");
   });
 
   it("disables select if resource pools have not loaded", () => {
diff --git a/src/app/base/components/SSHKeyList/index.ts b/src/app/base/components/SSHKeyList/index.ts
deleted file mode 100644
index d494b97e07..0000000000
--- a/src/app/base/components/SSHKeyList/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./SSHKeyList";
diff --git a/src/app/intro/views/UserIntro/UserIntro.tsx b/src/app/intro/views/UserIntro/UserIntro.tsx
index c6533e9e33..4c614f0192 100644
--- a/src/app/intro/views/UserIntro/UserIntro.tsx
+++ b/src/app/intro/views/UserIntro/UserIntro.tsx
@@ -4,12 +4,12 @@ import { ActionButton, Button, Card, Icon } from "@canonical/react-components";
 import { useDispatch, useSelector } from "react-redux";
 
 import { useListSshKeys } from "@/app/api/query/sshKeys";
-import SSHKeyForm from "@/app/base/components/SSHKeyForm";
-import SSHKeyList from "@/app/base/components/SSHKeyList";
 import TableConfirm from "@/app/base/components/TableConfirm";
 import { useCycled } from "@/app/base/hooks";
 import IntroCard from "@/app/intro/components/IntroCard";
 import IntroSection from "@/app/intro/components/IntroSection";
+import SSHKeyList from "@/app/preferences/views/SSHKeys/BaseSSHKeyList";
+import SSHKeyForm from "@/app/preferences/views/SSHKeys/SSHKeyForm";
 import authSelectors from "@/app/store/auth/selectors";
 import { userActions } from "@/app/store/user";
 import userSelectors from "@/app/store/user/selectors";
diff --git a/src/app/machines/views/Machines.tsx b/src/app/machines/views/Machines.tsx
index bc668c0d0c..eb476067d5 100644
--- a/src/app/machines/views/Machines.tsx
+++ b/src/app/machines/views/Machines.tsx
@@ -21,7 +21,6 @@ import { getSidePanelTitle } from "@/app/store/utils/node/base";
 const Machines = (): JSX.Element => {
   const navigate = useNavigate();
   const location = useLocation();
-  console.log(location);
   const currentFilters = FilterMachines.queryStringToFilters(location.search);
   // The filter state is initialised from the URL.
   const [searchFilter, setFilter] = useState(
diff --git a/src/app/pools/components/PoolsTable/PoolsTable.test.tsx b/src/app/pools/components/PoolsTable/PoolsTable.test.tsx
new file mode 100644
index 0000000000..cb65bbde5d
--- /dev/null
+++ b/src/app/pools/components/PoolsTable/PoolsTable.test.tsx
@@ -0,0 +1,97 @@
+import PoolsTable from "./PoolsTable";
+
+import * as factory from "@/testing/factories";
+import { poolsResolvers } from "@/testing/resolvers/pools";
+import {
+  renderWithProviders,
+  screen,
+  waitFor,
+  setupMockServer,
+} from "@/testing/utils";
+
+const mockServer = setupMockServer(
+  poolsResolvers.listPools.handler(),
+  poolsResolvers.getPool.handler()
+);
+
+describe("PoolsTable", () => {
+  it("disables the edit button without permissions", async () => {
+    mockServer.use(
+      poolsResolvers.listPools.handler({
+        items: [factory.resourcePool({ permissions: [] })],
+        total: 1,
+      })
+    );
+    renderWithProviders(<PoolsTable />);
+
+    await waitFor(() => {
+      expect(screen.getByRole("link", { name: "Edit" })).toHaveClass(
+        "is-disabled"
+      );
+    });
+  });
+
+  it("enables the edit button with correct permissions", async () => {
+    mockServer.use(
+      poolsResolvers.listPools.handler({
+        items: [factory.resourcePool({ permissions: ["edit"] })],
+        total: 1,
+      })
+    );
+
+    renderWithProviders(<PoolsTable />);
+
+    await waitFor(() => {
+      expect(screen.getByRole("link", { name: "Edit" })).not.toHaveClass(
+        "is-disabled"
+      );
+    });
+  });
+
+  it("disables the delete button for default pools", async () => {
+    mockServer.use(
+      poolsResolvers.listPools.handler({
+        items: [
+          factory.resourcePool({
+            id: 0,
+            name: "default",
+            description: "default",
+            is_default: true,
+            permissions: ["edit", "delete"],
+          }),
+        ],
+        total: 1,
+      })
+    );
+
+    renderWithProviders(<PoolsTable />);
+
+    await waitFor(() => {
+      expect(screen.getByRole("link", { name: "Delete" })).toBeAriaDisabled();
+    });
+  });
+
+  it("disables the delete button for pools that contain machines", async () => {
+    mockServer.use(
+      poolsResolvers.listPools.handler({
+        items: [
+          factory.resourcePool({
+            id: 0,
+            name: "machines",
+            description: "has machines",
+            is_default: false,
+            permissions: ["edit", "delete"],
+            machine_total_count: 1,
+          }),
+        ],
+        total: 1,
+      })
+    );
+
+    renderWithProviders(<PoolsTable />);
+
+    await waitFor(() => {
+      expect(screen.getByRole("link", { name: "Delete" })).toBeAriaDisabled();
+    });
+  });
+});
diff --git a/src/app/pools/components/PoolsTable/PoolsTable.tsx b/src/app/pools/components/PoolsTable/PoolsTable.tsx
new file mode 100644
index 0000000000..24cb8c2b33
--- /dev/null
+++ b/src/app/pools/components/PoolsTable/PoolsTable.tsx
@@ -0,0 +1,19 @@
+import usePoolsTableColumns from "./usePoolsTableColumns/usePoolsTableColumns";
+
+import { usePools } from "@/app/api/query/pools";
+import GenericTable from "@/app/base/components/GenericTable";
+
+const PoolsTable = () => {
+  const listPools = usePools();
+  const resourcePools = listPools.data?.items || [];
+  return (
+    <GenericTable
+      columns={usePoolsTableColumns()}
+      data={resourcePools}
+      noData="No pools found."
+      variant="full-height"
+    />
+  );
+};
+
+export default PoolsTable;
diff --git a/src/app/pools/components/PoolsTable/usePoolsTableColumns/usePoolsTableColumns.test.tsx b/src/app/pools/components/PoolsTable/usePoolsTableColumns/usePoolsTableColumns.test.tsx
new file mode 100644
index 0000000000..11895efa12
--- /dev/null
+++ b/src/app/pools/components/PoolsTable/usePoolsTableColumns/usePoolsTableColumns.test.tsx
@@ -0,0 +1,42 @@
+import { renderHook } from "@testing-library/react";
+
+import usePoolsTableColumns from "./usePoolsTableColumns";
+
+vi.mock("@/context", async () => {
+  const actual = await vi.importActual("@/context");
+  return {
+    ...actual!,
+  };
+});
+
+const setupTestCase = (name = "test-row") => {
+  const { result } = renderHook(() => usePoolsTableColumns());
+  const props = {
+    getValue: () => name,
+    row: {
+      original: {
+        name,
+        resource: {
+          name,
+        },
+      },
+      getIsSelected: vi.fn(() => false),
+      getCanSelect: vi.fn(() => true),
+      getToggleSelectedHandler: vi.fn(() => () => {}),
+      getIsGrouped: vi.fn(() => false),
+    },
+  };
+
+  return { result, props };
+};
+
+it("returns the correct number of columns", () => {
+  const { result } = setupTestCase();
+  expect(result.current).toBeInstanceOf(Array);
+  expect(result.current.map((column) => column.id)).toStrictEqual([
+    "name",
+    "machines",
+    "description",
+    "actions",
+  ]);
+});
diff --git a/src/app/pools/components/PoolsTable/usePoolsTableColumns/usePoolsTableColumns.tsx b/src/app/pools/components/PoolsTable/usePoolsTableColumns/usePoolsTableColumns.tsx
new file mode 100644
index 0000000000..eb01803a46
--- /dev/null
+++ b/src/app/pools/components/PoolsTable/usePoolsTableColumns/usePoolsTableColumns.tsx
@@ -0,0 +1,87 @@
+import { useMemo } from "react";
+
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { Link } from "react-router-dom";
+
+import type { ResourcePoolWithSummaryResponse } from "@/app/apiclient";
+import TableActions from "@/app/base/components/TableActions";
+import urls from "@/app/base/urls";
+import { FilterMachines } from "@/app/store/machine/utils";
+
+const getMachinesLabel = (row: Row<ResourcePoolWithSummaryResponse>) => {
+  if (row.original.machine_total_count === 0) {
+    return "Empty pool";
+  }
+  const filters = FilterMachines.filtersToQueryString({
+    pool: [`=${row.original.name}`],
+  });
+  return (
+    <Link to={`${urls.machines.index}${filters}`}>
+      {`${row.original.machine_ready_count} of ${row.original.machine_total_count} ready`}
+    </Link>
+  );
+};
+
+export type PoolsColumnDef = ColumnDef<
+  ResourcePoolWithSummaryResponse,
+  Partial<ResourcePoolWithSummaryResponse>
+>;
+
+const usePoolsTableColumns = () => {
+  return useMemo(
+    () =>
+      [
+        {
+          id: "name",
+          accessorKey: "name",
+          enableSorting: true,
+          header: "Name",
+        },
+        {
+          id: "machines",
+          accessorKey: "machine_total_count",
+          enableSorting: true,
+          header: "Machines",
+          cell: ({ row }) => {
+            return getMachinesLabel(row);
+          },
+        },
+        {
+          id: "description",
+          accessorKey: "description",
+          enableSorting: true,
+          header: "Description",
+        },
+        {
+          id: "actions",
+          accessorKey: "id",
+          enableSorting: false,
+          header: "Actions",
+          cell: ({ row }) => {
+            return (
+              <TableActions
+                deleteDisabled={
+                  !row.original.permissions.includes("delete") ||
+                  row.original.is_default ||
+                  row.original.machine_total_count > 0
+                }
+                deletePath={urls.pools.delete({ id: row.original.id })}
+                deleteTooltip={
+                  (row.original.is_default &&
+                    "The default pool may not be deleted.") ||
+                  (row.original.machine_total_count > 0 &&
+                    "Cannot delete a pool that contains machines.") ||
+                  null
+                }
+                editDisabled={!row.original.permissions.includes("edit")}
+                editPath={urls.pools.edit({ id: row.original.id })}
+              />
+            );
+          },
+        },
+      ] as PoolsColumnDef[],
+    []
+  );
+};
+
+export default usePoolsTableColumns;
diff --git a/src/app/pools/views/PoolList/PoolList.test.tsx b/src/app/pools/views/PoolList/PoolList.test.tsx
index b1a92a53cf..0616f5dbcc 100644
--- a/src/app/pools/views/PoolList/PoolList.test.tsx
+++ b/src/app/pools/views/PoolList/PoolList.test.tsx
@@ -4,13 +4,13 @@ import * as factory from "@/testing/factories";
 import { poolsResolvers } from "@/testing/resolvers/pools";
 import {
   screen,
-  within,
   renderWithMockStore,
   renderWithBrowserRouter,
   mockIsPending,
   renderWithProviders,
   setupMockServer,
   waitFor,
+  within,
 } from "@/testing/utils";
 
 const mockServer = setupMockServer(poolsResolvers.listPools.handler());
@@ -24,40 +24,6 @@ describe("PoolList", () => {
       expect(screen.getByText("Loading...")).toBeInTheDocument();
     });
   });
-
-  it("disables the edit button without permissions", async () => {
-    mockServer.use(
-      poolsResolvers.listPools.handler({
-        items: [factory.resourcePool({ permissions: [] })],
-        total: 1,
-      })
-    );
-    renderWithProviders(<PoolList />);
-
-    await waitFor(() => {
-      expect(screen.getByRole("link", { name: "Edit" })).toHaveClass(
-        "is-disabled"
-      );
-    });
-  });
-
-  it("enables the edit button with correct permissions", async () => {
-    mockServer.use(
-      poolsResolvers.listPools.handler({
-        items: [factory.resourcePool({ permissions: ["edit"] })],
-        total: 1,
-      })
-    );
-
-    renderWithProviders(<PoolList />);
-
-    await waitFor(() => {
-      expect(screen.getByRole("link", { name: "Edit" })).not.toHaveClass(
-        "is-disabled"
-      );
-    });
-  });
-
   it("displays a link to delete confirmation", async () => {
     mockServer.use(
       poolsResolvers.listPools.handler({
@@ -78,9 +44,10 @@ describe("PoolList", () => {
     renderWithProviders(<PoolList />);
 
     await waitFor(() => {
-      expect(screen.getByRole("row", { name: "squambo" })).toBeInTheDocument();
+      expect(screen.getByRole("cell", { name: "squambo" })).toBeInTheDocument();
     });
-    const row = screen.getByRole("row", { name: "squambo" });
+    const cell = screen.getByRole("cell", { name: "squambo" });
+    const row = cell.closest("tr")!;
     expect(row).not.toHaveClass("is-active");
 
     await waitFor(() => {
@@ -90,53 +57,6 @@ describe("PoolList", () => {
     });
   });
 
-  it("disables the delete button for default pools", async () => {
-    mockServer.use(
-      poolsResolvers.listPools.handler({
-        items: [
-          factory.resourcePool({
-            id: 0,
-            name: "default",
-            description: "default",
-            is_default: true,
-            permissions: ["edit", "delete"],
-          }),
-        ],
-        total: 1,
-      })
-    );
-
-    renderWithProviders(<PoolList />);
-
-    await waitFor(() => {
-      expect(screen.getByRole("link", { name: "Delete" })).toBeAriaDisabled();
-    });
-  });
-
-  it("disables the delete button for pools that contain machines", async () => {
-    mockServer.use(
-      poolsResolvers.listPools.handler({
-        items: [
-          factory.resourcePool({
-            id: 0,
-            name: "machines",
-            description: "has machines",
-            is_default: false,
-            permissions: ["edit", "delete"],
-            machine_total_count: 1,
-          }),
-        ],
-        total: 1,
-      })
-    );
-
-    renderWithProviders(<PoolList />);
-
-    await waitFor(() => {
-      expect(screen.getByRole("link", { name: "Delete" })).toBeAriaDisabled();
-    });
-  });
-
   it("does not show a machine link for empty pools", async () => {
     mockServer.use(
       poolsResolvers.listPools.handler({
@@ -149,9 +69,10 @@ describe("PoolList", () => {
 
     renderWithProviders(<PoolList />);
     await waitFor(() => {
-      expect(screen.getByRole("row", { name: "default" })).toBeInTheDocument();
+      expect(screen.getByRole("cell", { name: "default" })).toBeInTheDocument();
     });
-    const row = screen.getByRole("row", { name: "default" });
+    const cell = screen.getByRole("cell", { name: "default" });
+    const row = cell.closest("tr")!;
     expect(within(row).getByText("Empty pool")).toBeInTheDocument();
   });
 
@@ -171,12 +92,11 @@ describe("PoolList", () => {
 
     renderWithProviders(<PoolList />);
     await waitFor(() => {
-      expect(screen.getByRole("row", { name: "default" })).toBeInTheDocument();
+      expect(screen.getByRole("cell", { name: "default" })).toBeInTheDocument();
     });
-    const link = within(screen.getByRole("row", { name: "default" })).getByRole(
-      "link",
-      { name: "1 of 5 ready" }
-    );
+    const link = within(
+      screen.getByRole("cell", { name: "default" }).closest("tr")!
+    ).getByRole("link", { name: "1 of 5 ready" });
     expect(link).toBeInTheDocument();
     expect(link).toHaveAttribute("href", "/machines?pool=%3Ddefault");
   });
@@ -207,7 +127,7 @@ describe("PoolList", () => {
     renderWithBrowserRouter(<PoolList />, { route: "/pools" });
 
     await waitFor(() => {
-      expect(screen.getByText("No pools available.")).toBeInTheDocument();
+      expect(screen.getByText("No pools found.")).toBeInTheDocument();
     });
   });
 });
diff --git a/src/app/pools/views/PoolList/PoolList.tsx b/src/app/pools/views/PoolList/PoolList.tsx
index 3cb682ad01..05274947cd 100644
--- a/src/app/pools/views/PoolList/PoolList.tsx
+++ b/src/app/pools/views/PoolList/PoolList.tsx
@@ -1,87 +1,18 @@
-import {
-  Col,
-  Spinner,
-  MainTable,
-  Notification,
-  Row,
-} from "@canonical/react-components";
-import { Link } from "react-router-dom";
+import { Col, Spinner, Notification, Row } from "@canonical/react-components";
+
+import PoolsTable from "../../components/PoolsTable/PoolsTable";
 
 import { usePools } from "@/app/api/query/pools";
-import type { ResourcePoolWithSummaryResponse } from "@/app/apiclient";
-import TableActions from "@/app/base/components/TableActions";
 import { useWindowTitle } from "@/app/base/hooks";
-import urls from "@/app/base/urls";
-import { FilterMachines } from "@/app/store/machine/utils";
 
 export enum Label {
   Title = "Pool list",
 }
 
-const getMachinesLabel = (row: ResourcePoolWithSummaryResponse) => {
-  if (row.machine_total_count === 0) {
-    return "Empty pool";
-  }
-  const filters = FilterMachines.filtersToQueryString({
-    pool: [`=${row.name}`],
-  });
-  return (
-    <Link to={`${urls.machines.index}${filters}`}>
-      {`${row.machine_ready_count} of ${row.machine_total_count} ready`}
-    </Link>
-  );
-};
-
-const generateRows = (rows: ResourcePoolWithSummaryResponse[]) =>
-  rows.map((row) => {
-    return {
-      "aria-label": row.name,
-      columns: [
-        {
-          content: row.name,
-        },
-        {
-          content: getMachinesLabel(row),
-        },
-        {
-          content: row.description,
-        },
-        {
-          content: (
-            <TableActions
-              deleteDisabled={
-                !row.permissions.includes("delete") ||
-                row.is_default ||
-                row.machine_total_count > 0
-              }
-              deletePath={urls.pools.delete({ id: row.id })}
-              deleteTooltip={
-                (row.is_default && "The default pool may not be deleted.") ||
-                (row.machine_total_count > 0 &&
-                  "Cannot delete a pool that contains machines.") ||
-                null
-              }
-              editDisabled={!row.permissions.includes("edit")}
-              editPath={urls.pools.edit({ id: row.id })}
-            />
-          ),
-          className: "u-align--right",
-        },
-      ],
-      key: row.name,
-      sortData: {
-        name: row.name,
-        machines: row.machine_total_count,
-        description: row.description,
-      },
-    };
-  });
-
 const Pools = (): JSX.Element => {
   useWindowTitle("Pools");
 
   const listPools = usePools();
-  const resourcePools = listPools.data?.items || [];
 
   return (
     <div aria-label={Label.Title}>
@@ -102,35 +33,7 @@ const Pools = (): JSX.Element => {
                 <Spinner text="Loading..." />
               </div>
             )}
-            {listPools.isSuccess && (
-              <MainTable
-                className="p-table-expanding--light"
-                defaultSortDirection="ascending"
-                emptyStateMsg="No pools available."
-                expanding={true}
-                headers={[
-                  {
-                    content: "Name",
-                    sortKey: "name",
-                  },
-                  {
-                    content: "Machines",
-                    sortKey: "machines",
-                  },
-                  {
-                    content: "Description",
-                    sortKey: "description",
-                  },
-                  {
-                    content: "Actions",
-                    className: "u-align--right",
-                  },
-                ]}
-                paginate={50}
-                rows={generateRows(resourcePools)}
-                sortable
-              />
-            )}
+            {listPools.isSuccess && <PoolsTable />}
           </div>
         </Col>
       </Row>
diff --git a/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx b/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx
index d7171668a8..b4520b6454 100644
--- a/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx
+++ b/src/app/preferences/views/SSHKeys/AddSSHKey/AddSSHKey.tsx
@@ -1,8 +1,8 @@
 import { useOnEscapePressed } from "@canonical/react-components";
 import { useNavigate } from "react-router-dom";
 
-import SSHKeyForm from "@/app/base/components/SSHKeyForm";
 import urls from "@/app/base/urls";
+import SSHKeyForm from "@/app/preferences/views/SSHKeys/SSHKeyForm";
 
 export enum Label {
   Title = "Add SSH key",
diff --git a/src/app/base/components/SSHKeyList/SSHKeyList.test.tsx b/src/app/preferences/views/SSHKeys/BaseSSHKeyList/BaseSSHKeyList.test.tsx
similarity index 99%
rename from src/app/base/components/SSHKeyList/SSHKeyList.test.tsx
rename to src/app/preferences/views/SSHKeys/BaseSSHKeyList/BaseSSHKeyList.test.tsx
index 2d955ca922..67da9fe75c 100644
--- a/src/app/base/components/SSHKeyList/SSHKeyList.test.tsx
+++ b/src/app/preferences/views/SSHKeys/BaseSSHKeyList/BaseSSHKeyList.test.tsx
@@ -1,4 +1,4 @@
-import SSHKeyList from "./SSHKeyList";
+import SSHKeyList from "./BaseSSHKeyList";
 
 import * as sidePanelHooks from "@/app/base/side-panel-context";
 import urls from "@/app/preferences/urls";
diff --git a/src/app/base/components/SSHKeyList/SSHKeyList.tsx b/src/app/preferences/views/SSHKeys/BaseSSHKeyList/BaseSSHKeyList.tsx
similarity index 97%
rename from src/app/base/components/SSHKeyList/SSHKeyList.tsx
rename to src/app/preferences/views/SSHKeys/BaseSSHKeyList/BaseSSHKeyList.tsx
index c93f237cd8..995e3b8848 100644
--- a/src/app/base/components/SSHKeyList/SSHKeyList.tsx
+++ b/src/app/preferences/views/SSHKeys/BaseSSHKeyList/BaseSSHKeyList.tsx
@@ -107,7 +107,7 @@ const generateRows = (sshkeys: SshKeyResponse[], navigate: NavigateFunction) =>
     };
   });
 
-const SSHKeyList = ({ ...tableProps }: Props): JSX.Element => {
+const BaseSSHKeyList = ({ ...tableProps }: Props): JSX.Element => {
   const { data, failureReason, isPending, isFetched } = useListSshKeys();
   const navigate = useNavigate();
   const sshkeys = data?.items ?? [];
@@ -146,4 +146,4 @@ const SSHKeyList = ({ ...tableProps }: Props): JSX.Element => {
   );
 };
 
-export default SSHKeyList;
+export default BaseSSHKeyList;
diff --git a/src/app/base/components/SSHKeyList/_index.scss b/src/app/preferences/views/SSHKeys/BaseSSHKeyList/_index.scss
similarity index 100%
rename from src/app/base/components/SSHKeyList/_index.scss
rename to src/app/preferences/views/SSHKeys/BaseSSHKeyList/_index.scss
diff --git a/src/app/preferences/views/SSHKeys/BaseSSHKeyList/index.ts b/src/app/preferences/views/SSHKeys/BaseSSHKeyList/index.ts
new file mode 100644
index 0000000000..7b0e346b03
--- /dev/null
+++ b/src/app/preferences/views/SSHKeys/BaseSSHKeyList/index.ts
@@ -0,0 +1 @@
+export { default } from "./BaseSSHKeyList";
diff --git a/src/app/base/components/SSHKeyForm/SSHKeyForm.test.tsx b/src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyForm.test.tsx
similarity index 100%
rename from src/app/base/components/SSHKeyForm/SSHKeyForm.test.tsx
rename to src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyForm.test.tsx
diff --git a/src/app/base/components/SSHKeyForm/SSHKeyForm.tsx b/src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyForm.tsx
similarity index 100%
rename from src/app/base/components/SSHKeyForm/SSHKeyForm.tsx
rename to src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyForm.tsx
diff --git a/src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.test.tsx b/src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.test.tsx
similarity index 100%
rename from src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.test.tsx
rename to src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.test.tsx
diff --git a/src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx b/src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx
similarity index 100%
rename from src/app/base/components/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx
rename to src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyFormFields/SSHKeyFormFields.tsx
diff --git a/src/app/base/components/SSHKeyForm/SSHKeyFormFields/index.ts b/src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyFormFields/index.ts
similarity index 100%
rename from src/app/base/components/SSHKeyForm/SSHKeyFormFields/index.ts
rename to src/app/preferences/views/SSHKeys/SSHKeyForm/SSHKeyFormFields/index.ts
diff --git a/src/app/base/components/SSHKeyForm/index.ts b/src/app/preferences/views/SSHKeys/SSHKeyForm/index.ts
similarity index 100%
rename from src/app/base/components/SSHKeyForm/index.ts
rename to src/app/preferences/views/SSHKeys/SSHKeyForm/index.ts
diff --git a/src/app/base/components/SSHKeyForm/types.ts b/src/app/preferences/views/SSHKeys/SSHKeyForm/types.ts
similarity index 100%
rename from src/app/base/components/SSHKeyForm/types.ts
rename to src/app/preferences/views/SSHKeys/SSHKeyForm/types.ts
diff --git a/src/app/preferences/views/SSHKeys/SSHKeyList/SSHKeyList.tsx b/src/app/preferences/views/SSHKeys/SSHKeyList/SSHKeyList.tsx
index f42b61b667..d32e2c6118 100644
--- a/src/app/preferences/views/SSHKeys/SSHKeyList/SSHKeyList.tsx
+++ b/src/app/preferences/views/SSHKeys/SSHKeyList/SSHKeyList.tsx
@@ -1,9 +1,9 @@
 import { ExternalLink } from "@canonical/maas-react-components";
 
-import BaseSSHKeyList from "@/app/base/components/SSHKeyList";
 import docsUrls from "@/app/base/docsUrls";
 import { useWindowTitle } from "@/app/base/hooks";
 import urls from "@/app/base/urls";
+import BaseSSHKeyList from "@/app/preferences/views/SSHKeys/BaseSSHKeyList";
 
 export enum Label {
   Title = "SSH keys",
diff --git a/src/scss/index.scss b/src/scss/index.scss
index 9fb506f826..d3e924a47a 100644
--- a/src/scss/index.scss
+++ b/src/scss/index.scss
@@ -110,7 +110,6 @@
 @import "@/app/base/components/SecondaryNavigation";
 @import "@/app/base/components/SectionHeader";
 @import "@/app/base/components/SelectButton";
-@import "@/app/base/components/SSHKeyList";
 @import "@/app/base/components/ShowAdvanced";
 @import "@/app/base/components/TableMenu";
 @import "@/app/base/components/TagSelector";
@@ -145,7 +144,6 @@
 @include SecondaryNavigation;
 @include SectionHeader;
 @include SelectButton;
-@include SSHKeyList;
 @include ShowAdvanced;
 @include TableMenu;
 @include TagSelector;
@@ -263,9 +261,11 @@
 @import "@/app/preferences/views/APIKeys/APIKeyList";
 @import "@/app/preferences/views/SSLKeys/AddSSLKey";
 @import "@/app/preferences/views/SSLKeys/SSLKeyList";
+@import "@/app/preferences/views/SSHKeys/BaseSSHKeyList";
 @include APIKeyList;
 @include AddSSLKey;
 @include SSLKeyList;
+@include SSHKeyList;
 
 // settings
 @import "@/app/settings/components/SettingsTable";


Reviews:
user: login: abuyukyi101198
body: Looks decent, but there are a few details that need attention
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T07:03:43Z
user: login: rumanowskak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T08:00:34Z
user: login: rumanowskak
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T08:01:39Z
user: login: abuyukyi101198
body: LGTM 👍
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:10:20Z
user: login: abuyukyi101198
body: There are two errors unrelated to your work, but should be fixed before you can merge this PR.

First, there is leftover `console.log` statement in `src/app/machines/views/Machines.tsx`. You should delete that `console.log`.

Second, is a test that started to fail after our migration to v3 for pools. I think it has to do with the timing changes in the backend because of that migration, but that singular failing test requires better checking to pass.

Replace the contents of `src/app/base/components/ResourcePoolSelect/ResourcePoolSelect.test.tsx` with:
```ts
import { Formik } from "formik";

import ResourcePoolSelect from "./ResourcePoolSelect";

import { mockPools, poolsResolvers } from "@/testing/resolvers/pools";
import {
  renderWithProviders,
  screen,
  setupMockServer,
  waitFor,
} from "@/testing/utils";

setupMockServer(poolsResolvers.listPools.handler());

describe("ResourcePoolSelect", () => {
  it("renders a list of all resource pools in state", async () => {
    renderWithProviders(
      <Formik initialValues={{ pool: "" }} onSubmit={vi.fn()}>
        <ResourcePoolSelect name="pool" />
      </Formik>
    );

    await waitFor(() => expect(poolsResolvers.listPools.resolved).toBeTruthy());

    await waitFor(() =>
      expect(
        screen.getByRole("option", { name: "swimming" })
      ).toBeInTheDocument()
    );
    const pools = screen.getAllByRole("option");
    expect(pools).toHaveLength(mockPools.items.length + 1);
  });

  it("disables select if resource pools have not loaded", () => {
    renderWithProviders(
      <Formik initialValues={{ pool: "" }} onSubmit={vi.fn()}>
        <ResourcePoolSelect name="pool" />
      </Formik>
    );

    expect(screen.getByRole("combobox")).toBeDisabled();
  });
});
```
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T11:26:28Z
user: login: abuyukyi101198
body: Perfect
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T12:38:57Z

Review comments:
diff_hunk: @@ -0,0 +1,47 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { RowSelectionState } from "@tanstack/react-table";
+
+import type { Pool } from "../../types";
+
+import usePoolsTableColumns from "./usePoolsTableColumns/usePoolsTableColumns";
+
+import { usePools } from "@/app/api/query/pools";
+import type { ResourcePoolWithSummaryResponse } from "@/app/apiclient";
+import GenericTable from "@/app/base/components/GenericTable";
+
+type SMPoolsTableProps = {
path: src/app/pools/components/PoolsTable/PoolsTable.tsx
user: login: abuyukyi101198
body: ```suggestion
type PoolsTableProps = {
```
SM was a prefix for Site Manager styled components, we don't need that here anymore 🙂
created_at: 2025-03-27T06:28:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { RowSelectionState } from "@tanstack/react-table";
+
+import type { Pool } from "../../types";
+
+import usePoolsTableColumns from "./usePoolsTableColumns/usePoolsTableColumns";
+
+import { usePools } from "@/app/api/query/pools";
+import type { ResourcePoolWithSummaryResponse } from "@/app/apiclient";
+import GenericTable from "@/app/base/components/GenericTable";
+
+type SMPoolsTableProps = {
+  selectedRows: RowSelectionState;
+  setSelectedRows: Dispatch<SetStateAction<RowSelectionState>>;
+};
+
+const getPools = (resources: ResourcePoolWithSummaryResponse[]): Pool[] => {
+  return resources.map((resource) => {
+    return {
+      id: resource.id,
+      name: resource.name,
+      machines: resource,
+      description: resource.description,
+      resource: resource,
+    };
+  });
+};
+
+const PoolsTable = ({ selectedRows, setSelectedRows }: SMPoolsTableProps) => {
+  const listPools = usePools();
+  const resourcePools = listPools.data?.items || [];
+  const pools = getPools(resourcePools);
+  return (
+    <GenericTable
+      canSelect
+      columns={usePoolsTableColumns()}
+      data={pools}
+      noData="No pools found."
+      rowSelection={selectedRows}
+      setRowSelection={setSelectedRows}
+      variant="full-height"
+    />
path: src/app/pools/components/PoolsTable/PoolsTable.tsx
user: login: abuyukyi101198
body: ```suggestion
    <GenericTable
      columns={usePoolsTableColumns()}
      data={pools}
      noData="No pools found."
      variant="full-height"
    />
```
The previous implementation of the `PoolsTable` does not have selection, so let's keep it the same.
created_at: 2025-03-27T06:33:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+import type { ResourcePoolWithSummaryResponse } from "../apiclient/types.gen";
+
+export type Pool = {
+  id: number;
+  name: string;
+  machines: ResourcePoolWithSummaryResponse;
+  description: string;
+  resource: ResourcePoolWithSummaryResponse;
+};
path: src/app/pools/types.ts
user: login: abuyukyi101198
body: I don't think you should define an additional `Pool` type, and should use the `ResourcePoolWithSummaryResponse` directly. I assume you saw this in the `ImagesTable`, but images are still at v2, and require such parsing.

`ZonesListTable` and `useZonesTableColumns` is a better example for v3.
created_at: 2025-03-27T06:39:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { RowSelectionState } from "@tanstack/react-table";
+
+import type { Pool } from "../../types";
+
+import usePoolsTableColumns from "./usePoolsTableColumns/usePoolsTableColumns";
+
+import { usePools } from "@/app/api/query/pools";
+import type { ResourcePoolWithSummaryResponse } from "@/app/apiclient";
+import GenericTable from "@/app/base/components/GenericTable";
+
+type SMPoolsTableProps = {
+  selectedRows: RowSelectionState;
+  setSelectedRows: Dispatch<SetStateAction<RowSelectionState>>;
+};
+
+const getPools = (resources: ResourcePoolWithSummaryResponse[]): Pool[] => {
+  return resources.map((resource) => {
+    return {
+      id: resource.id,
+      name: resource.name,
+      machines: resource,
+      description: resource.description,
+      resource: resource,
+    };
+  });
+};
path: src/app/pools/components/PoolsTable/PoolsTable.tsx
user: login: abuyukyi101198
body: Shouldn't need `Pool` type, mentioned in the above comment
created_at: 2025-03-27T06:45:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -145,7 +144,6 @@
 @include SecondaryNavigation;
 @include SectionHeader;
 @include SelectButton;
-@include SSHKeyList;
path: src/scss/index.scss
user: login: abuyukyi101198
body: You should add this line back in, under`// preferences`
created_at: 2025-03-27T07:02:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { RowSelectionState } from "@tanstack/react-table";
+
+import type { Pool } from "../../types";
+
+import usePoolsTableColumns from "./usePoolsTableColumns/usePoolsTableColumns";
+
+import { usePools } from "@/app/api/query/pools";
+import type { ResourcePoolWithSummaryResponse } from "@/app/apiclient";
+import GenericTable from "@/app/base/components/GenericTable";
+
+type SMPoolsTableProps = {
path: src/app/pools/components/PoolsTable/PoolsTable.tsx
user: login: rumanowskak
body: but in PoolTable component I am still using those props and have to define its type
created_at: 2025-03-27T08:00:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import type { Dispatch, SetStateAction } from "react";
+
+import type { RowSelectionState } from "@tanstack/react-table";
+
+import type { Pool } from "../../types";
+
+import usePoolsTableColumns from "./usePoolsTableColumns/usePoolsTableColumns";
+
+import { usePools } from "@/app/api/query/pools";
+import type { ResourcePoolWithSummaryResponse } from "@/app/apiclient";
+import GenericTable from "@/app/base/components/GenericTable";
+
+type SMPoolsTableProps = {
path: src/app/pools/components/PoolsTable/PoolsTable.tsx
user: login: rumanowskak
body: Oh I saw your next comment, never mind :)
created_at: 2025-03-27T08:01:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+import type { ResourcePoolWithSummaryResponse } from "../apiclient/types.gen";
+
+export type Pool = {
+  id: number;
+  name: string;
+  machines: ResourcePoolWithSummaryResponse;
+  description: string;
+  resource: ResourcePoolWithSummaryResponse;
+};
path: src/app/pools/types.ts
user: login: abuyukyi101198
body: Remove this file, it is not used anymore
created_at: 2025-03-27T11:20:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: webteam-app
created_at: 2025-03-26T20:04:07Z
body: ### [<img src='https://assets.ubuntu.com/v1/6baef514-ubuntu-circle-of-friends-large.svg' height=32 width=32> Demo</img>](https://maas-ui-5668.demos.haus)
### [<img src='https://assets.ubuntu.com/v1/e512b0e2-jenkins.svg' height=32 width=32> Jenkins </img>](https://jenkins.canonical.com/webteam/job/start-demo/7814)
### [<img src='https://assets.ubuntu.com/v1/7144ec6d-logo-jaas-icon.svg' height=32 width=32> demos.haus </img>](https://demos.haus)

author_association: NONE
user: login: abuyukyi101198
created_at: 2025-03-27T11:12:13Z
body: The `types` file in `pools` should be removed, but I took note of that and will take care of it in the next PR. It is not major enough of a matter to delay this PR any longer.
author_association: CONTRIBUTOR