← Back to team overview

sts-sponsors team mailing list archive

[Merge] maas-site-manager:feat-table-component-MAASENG-1387 into maas-site-manager:main

 

Peter Makowski has proposed merging maas-site-manager:feat-table-component-MAASENG-1387 into maas-site-manager:main.

Requested reviews:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~maas-committers/maas-site-manager/+git/site-manager/+merge/437995
-- 
Your team MAAS Committers is requested to review the proposed merge of maas-site-manager:feat-table-component-MAASENG-1387 into maas-site-manager:main.
diff --git a/.env b/.env
index 22cb9fd..797df3b 100644
--- a/.env
+++ b/.env
@@ -1 +1,2 @@
-UI_PORT=8405
\ No newline at end of file
+VITE_UI_PORT=8405
+VITE_API_URL=http://localhost:8000
diff --git a/frontend/package.json b/frontend/package.json
index cfa7539..c70b8d3 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,6 +11,12 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@canonical/react-components": "0.38.0",
+    "@tanstack/react-table": "8.7.9",
+    "axios": "1.3.4",
+    "date-fns": "2.29.3",
+    "date-fns-tz": "2.0.0",
+    "lodash": "4.17.21",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-query": "3.39.3",
@@ -18,15 +24,19 @@
     "vanilla-framework": "3.11.0"
   },
   "devDependencies": {
-    "@playwright/test": "^1.31.1",
+    "@playwright/test": "1.31.1",
     "@testing-library/jest-dom": "5.16.5",
     "@testing-library/react": "13.4.0",
+    "@types/axios": "0.14.0",
+    "@types/lodash": "4.14.191",
     "@types/react": "18.0.27",
     "@types/react-dom": "18.0.10",
     "@vitejs/plugin-react-swc": "3.0.0",
-    "dotenv": "^16.0.3",
+    "dotenv": "16.0.3",
+    "mockdate": "3.0.5",
     "msw": "1.0.1",
     "sass": "1.58.1",
+    "timezone-mock": "1.3.6",
     "typescript": "4.9.3",
     "vite": "4.1.0",
     "vitest": "0.28.5"
diff --git a/frontend/setupTests.js b/frontend/setupTests.ts
similarity index 90%
rename from frontend/setupTests.js
rename to frontend/setupTests.ts
index 45657de..d025d9d 100644
--- a/frontend/setupTests.js
+++ b/frontend/setupTests.ts
@@ -1,7 +1,10 @@
+import dotenv from "dotenv";
 import { expect, afterEach } from "vitest";
 import { cleanup } from "@testing-library/react";
 import matchers from "@testing-library/jest-dom/matchers";
 
+dotenv.config({ path: "../.env" });
+
 // extends Vitest's expect method with methods from react-testing-library
 expect.extend(matchers);
 
diff --git a/frontend/src/App.scss b/frontend/src/App.scss
index 6049fbf..9a5b6a7 100644
--- a/frontend/src/App.scss
+++ b/frontend/src/App.scss
@@ -1,5 +1,6 @@
 @import "node_modules/vanilla-framework";
 @include vf-base;
+@include vanilla;
 // @include vf-p-table-sortable;
 // @include vf-p-table-expanding;
 @include vf-p-grid;
diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts
new file mode 100644
index 0000000..9b40510
--- /dev/null
+++ b/frontend/src/api/api.ts
@@ -0,0 +1,7 @@
+import axios from "axios";
+
+const api = axios.create({
+  baseURL: import.meta.env.VITE_API_URL,
+});
+
+export default api;
diff --git a/frontend/src/api/handlers.ts b/frontend/src/api/handlers.ts
new file mode 100644
index 0000000..8877ca6
--- /dev/null
+++ b/frontend/src/api/handlers.ts
@@ -0,0 +1,11 @@
+import api from "./api";
+import urls from "./urls";
+
+export const getSites = async () => {
+  try {
+    const response = await api.get(urls.sites);
+    return response.data;
+  } catch (error) {
+    console.error(error);
+  }
+};
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
new file mode 100644
index 0000000..9e19686
--- /dev/null
+++ b/frontend/src/api/index.ts
@@ -0,0 +1 @@
+export { default } from "./api";
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
new file mode 100644
index 0000000..c8495ac
--- /dev/null
+++ b/frontend/src/api/types.ts
@@ -0,0 +1,26 @@
+export type Site = {
+  name: string;
+  url: string; // <full URL including protocol>,
+  connection: "stable" | "stale" | "lost";
+  last_seen: string; // <ISO 8601 date>,
+  address: {
+    countrycode: string; // <alpha2 country code>,
+    city: string;
+    zip: string;
+    street: string;
+  };
+  timezone: string; // <three letter abbreviation>,
+  stats: {
+    machines: number;
+    occupied_machines: number;
+    ready_machines: number;
+    error_machines: number;
+  };
+};
+
+export type Sites = {
+  items: Site[];
+  total: number;
+  page: number;
+  size: number;
+};
diff --git a/frontend/src/api/urls.ts b/frontend/src/api/urls.ts
new file mode 100644
index 0000000..0a3217a
--- /dev/null
+++ b/frontend/src/api/urls.ts
@@ -0,0 +1,7 @@
+import { getApiUrl } from "./utils";
+
+const urls = {
+  sites: getApiUrl("/api/sites"),
+};
+
+export default urls;
diff --git a/frontend/src/api/utils.ts b/frontend/src/api/utils.ts
new file mode 100644
index 0000000..7386f0f
--- /dev/null
+++ b/frontend/src/api/utils.ts
@@ -0,0 +1,3 @@
+export const getApiUrl = (path: string) => {
+  return new URL(path, import.meta.env.VITE_API_URL).toString();
+};
diff --git a/frontend/src/components/MainLayout/MainLayout.scss b/frontend/src/components/MainLayout/MainLayout.scss
new file mode 100644
index 0000000..12ae5c8
--- /dev/null
+++ b/frontend/src/components/MainLayout/MainLayout.scss
@@ -0,0 +1,3 @@
+.l-main.is-maas-site-manager {
+  margin-top: 1.5rem;
+}
diff --git a/frontend/src/components/MainLayout/MainLayout.tsx b/frontend/src/components/MainLayout/MainLayout.tsx
index 9b55505..8218511 100644
--- a/frontend/src/components/MainLayout/MainLayout.tsx
+++ b/frontend/src/components/MainLayout/MainLayout.tsx
@@ -1,11 +1,12 @@
 import { Outlet } from "react-router-dom";
+import "./MainLayout.scss";
 
 const MainLayout = () => (
   <div className="l-application">
-    <main className="l-main">
+    <main className="l-main is-maas-site-manager">
       <div className="row">
         <div className="col-12">
-          <h1>MAAS Site Manager</h1>
+          <h1 className="u-hide">MAAS Site Manager</h1>
           <Outlet />
         </div>
       </div>
diff --git a/frontend/src/components/SitesList/SitesList.scss b/frontend/src/components/SitesList/SitesList.scss
deleted file mode 100644
index cad2f95..0000000
--- a/frontend/src/components/SitesList/SitesList.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-table {
-  table-layout: auto;
-}
diff --git a/frontend/src/components/SitesList/SitesList.test.tsx b/frontend/src/components/SitesList/SitesList.test.tsx
index e23e81c..75cf9c5 100644
--- a/frontend/src/components/SitesList/SitesList.test.tsx
+++ b/frontend/src/components/SitesList/SitesList.test.tsx
@@ -1,5 +1,21 @@
 import SitesList from "./SitesList";
-import { render, screen } from "../../test-utils";
+import { render, screen, waitFor, within } from "../../test-utils";
+import { createMockGetServer } from "../../mocks/server";
+import { sites } from "../../mocks/factories";
+import urls from "../../api/urls";
+
+const sitesData = sites();
+const mockServer = createMockGetServer(urls.sites, sitesData);
+
+beforeAll(() => {
+  mockServer.listen();
+});
+afterEach(() => {
+  mockServer.resetHandlers();
+});
+afterAll(() => {
+  mockServer.close();
+});
 
 it("renders header", () => {
   render(<SitesList />);
@@ -8,3 +24,30 @@ it("renders header", () => {
     screen.getByRole("heading", { name: /MAAS Regions/i })
   ).toBeInTheDocument();
 });
+
+it("displays loading text", () => {
+  render(<SitesList />);
+
+  expect(screen.getByText(/loading/i)).toBeInTheDocument();
+});
+
+it("displays populated sites table", async () => {
+  const { items } = sitesData;
+  render(<SitesList />);
+
+  await waitFor(() =>
+    expect(screen.getByRole("table", { name: /sites/i })).toBeInTheDocument()
+  );
+
+  expect(screen.getAllByRole("rowgroup")).toHaveLength(2);
+  expect(
+    screen.getByRole("heading", { name: /2 MAAS Regions/i })
+  ).toBeInTheDocument();
+  const tableBody = screen.getAllByRole("rowgroup")[1];
+  expect(within(tableBody).getAllByRole("row")).toHaveLength(items.length);
+  within(tableBody)
+    .getAllByRole("row")
+    .forEach((row, i) =>
+      expect(row).toHaveTextContent(new RegExp(items[i].name, "i"))
+    );
+});
diff --git a/frontend/src/components/SitesList/SitesList.tsx b/frontend/src/components/SitesList/SitesList.tsx
index 9a49bd0..571a440 100644
--- a/frontend/src/components/SitesList/SitesList.tsx
+++ b/frontend/src/components/SitesList/SitesList.tsx
@@ -1,54 +1,18 @@
-import { useQuery } from "react-query";
-import SiteRow from "./components/SiteRow";
-import "./SitesList.scss";
-import { Sites } from "./types";
+import { useSitesQuery } from "../../hooks/api";
+import SitesTable from "./components/SitesTable";
 
 const SitesList = () => {
-  const query = useQuery<Sites>("/api/sites", async function () {
-    const response = await fetch("/api/sites");
-    if (!response.ok) {
-      throw new Error("Network response was not ok");
-    }
-    const responseJson = await response.json();
-    return responseJson;
-  });
+  const query = useSitesQuery();
 
   return (
     <div>
-      <h2>{query?.data?.items?.length || ""} MAAS Regions</h2>
-      <table>
-        <thead>
-          <tr>
-            <th>
-              <div>MAAS region alias</div>
-              <div>URL</div>
-            </th>
-            <th>
-              <div>connection</div>
-              <div>last seen</div>
-            </th>
-            <th>
-              <div>country</div>
-              <div>street, city, zip</div>
-            </th>
-            <th>
-              <div>local time</div>
-              <div>timezone</div>
-            </th>
-            <th>
-              <div>total number of machines</div>
-              <div>machines per aggregated status</div>
-            </th>
-          </tr>
-        </thead>
-        <tbody>
-          {query.data
-            ? query.data.items.map((site) => (
-                <SiteRow key={site.url} site={site} />
-              ))
-            : null}
-        </tbody>
-      </table>
+      <h2 className="p-heading--4">
+        {query?.data?.items?.length || ""} MAAS Regions
+      </h2>
+      {query.isLoading && !query.isFetchedAfterMount ? "Loading..." : null}
+      {query.isFetchedAfterMount && query.data ? (
+        <SitesTable data={query.data.items} />
+      ) : null}
     </div>
   );
 };
diff --git a/frontend/src/components/SitesList/components/SiteRow.tsx b/frontend/src/components/SitesList/components/SiteRow.tsx
deleted file mode 100644
index 22ed089..0000000
--- a/frontend/src/components/SitesList/components/SiteRow.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Site } from "../types";
-
-const SiteRow = ({ site }: { site: Site }) => {
-  return (
-    <tr>
-      <td>
-        <div>{site.name}</div>
-        <div>
-          <a href={site.url}>{site.url}</a>
-        </div>
-      </td>
-      <td>
-        <div>{site.connection}</div>
-        <div>{site.last_seen}</div>
-      </td>
-      <td>
-        <div>{site.address.countrycode}</div>
-        <div>
-          {site.address.street}, {site.address.city}, {site.address.zip}
-        </div>
-      </td>
-      <td>
-        <div>11:00 (local time)</div>
-        <div>{site.timezone}</div>
-      </td>
-      <td>
-        <div>{site.stats.machines}</div>
-        <div>
-          Ready: {site.stats.ready_machines}, Occupied:
-          {site.stats.occupied_machines}, Error: {site.stats.error_machines}
-        </div>
-      </td>
-    </tr>
-  );
-};
-
-export default SiteRow;
diff --git a/frontend/src/components/SitesList/components/SitesTable.test.tsx b/frontend/src/components/SitesList/components/SitesTable.test.tsx
new file mode 100644
index 0000000..4fd2a23
--- /dev/null
+++ b/frontend/src/components/SitesList/components/SitesTable.test.tsx
@@ -0,0 +1,49 @@
+import SitesTable from "./SitesTable";
+import { render, screen, within } from "../../../test-utils";
+import { sites, site } from "../../../mocks/factories";
+import { Site } from "../../../api/types";
+import MockDate from "mockdate";
+import timezoneMock from "timezone-mock";
+import { vi } from "vitest";
+
+beforeEach(() => {
+  vi.useFakeTimers();
+  timezoneMock.register("Etc/GMT");
+});
+
+afterEach(() => {
+  timezoneMock.unregister();
+  vi.useRealTimers();
+});
+
+it("displays an empty sites table", () => {
+  render(<SitesTable data={[]} />);
+
+  expect(screen.getByRole("table", { name: /sites/i })).toBeInTheDocument();
+});
+
+it("displays rows with details for each site", () => {
+  const items = sites().items as Site[];
+  render(<SitesTable data={items} />);
+
+  expect(screen.getByRole("table", { name: /sites/i })).toBeInTheDocument();
+
+  const tableBody = screen.getAllByRole("rowgroup")[1];
+  expect(within(tableBody).getAllByRole("row")).toHaveLength(items.length);
+  within(tableBody)
+    .getAllByRole("row")
+    .forEach((row, i) =>
+      expect(row).toHaveTextContent(new RegExp(items[i].name, "i"))
+    );
+});
+
+it("displays correct local time", () => {
+  const date = new Date("2000-01-01T12:00:00Z");
+  vi.setSystemTime(date);
+
+  const item = site({ timezone: "CET" });
+  render(<SitesTable data={[item]} />);
+
+  expect(screen.getByRole("table", { name: /sites/i })).toBeInTheDocument();
+  expect(screen.getByText(/13:00 \(local time\)/i)).toBeInTheDocument();
+});
diff --git a/frontend/src/components/SitesList/components/SitesTable.tsx b/frontend/src/components/SitesList/components/SitesTable.tsx
new file mode 100644
index 0000000..b6137e6
--- /dev/null
+++ b/frontend/src/components/SitesList/components/SitesTable.tsx
@@ -0,0 +1,232 @@
+import {
+  useReactTable,
+  flexRender,
+  ColumnDef,
+  getCoreRowModel,
+  getFilteredRowModel,
+  getPaginationRowModel,
+} from "@tanstack/react-table";
+import { Input } from "@canonical/react-components";
+import { useMemo, useState } from "react";
+import pick from "lodash/fp/pick";
+import { format } from "date-fns";
+import { utcToZonedTime } from "date-fns-tz";
+import { Site } from "../../../api/types";
+
+const createAccessor =
+  <T, K extends keyof T>(keys: K[] | K) =>
+  (row: T) =>
+    pick(keys, row);
+
+const SitesTable = ({ data }: { data: Site[] }) => {
+  const [columnVisibility, setColumnVisibility] = useState({});
+
+  const columns = useMemo<ColumnDef<Site, Partial<Site>>[]>(
+    () => [
+      {
+        id: "select",
+        header: ({ table }) => (
+          <div>
+            <Input
+              type="checkbox"
+              {...{
+                checked: table.getIsAllRowsSelected(),
+                indeterminate: table.getIsSomeRowsSelected(),
+                onChange: table.getToggleAllRowsSelectedHandler(),
+              }}
+            />
+          </div>
+        ),
+        cell: ({ row }) => (
+          <div>
+            <Input
+              type="checkbox"
+              {...{
+                checked: row.getIsSelected(),
+                disabled: !row.getCanSelect(),
+                indeterminate: row.getIsSomeSelected(),
+                onChange: row.getToggleSelectedHandler(),
+              }}
+            />
+          </div>
+        ),
+      },
+      {
+        id: "name",
+        accessorFn: createAccessor(["name", "url"]),
+        header: () => (
+          <>
+            <div>Name</div>
+            <div className="u-text--muted">URL</div>
+          </>
+        ),
+        cell: ({ getValue }) => (
+          <>
+            <div>{getValue().name}</div>
+            <div className="u-text--muted">{getValue().url}</div>
+          </>
+        ),
+      },
+      {
+        id: "connection",
+        accessorFn: createAccessor(["connection", "last_seen"]),
+        header: () => (
+          <>
+            <div>connection</div>
+            <div className="u-text--muted">last seen</div>
+          </>
+        ),
+        cell: ({ getValue }) => (
+          <>
+            <div>{getValue().connection}</div>
+            <div className="u-text--muted">{getValue().last_seen}</div>
+          </>
+        ),
+      },
+      {
+        id: "address",
+        accessorFn: createAccessor("address"),
+        header: () => (
+          <>
+            <div>country</div>
+            <div className="u-text--muted">street, city, ZIP</div>
+          </>
+        ),
+        cell: ({ getValue }) => {
+          const { address } = getValue();
+          const { countrycode, city, zip, street } = address || {};
+          return (
+            <>
+              <div>{countrycode}</div>
+              <div className="u-text--muted">
+                {street}, {city}, {zip}
+              </div>
+            </>
+          );
+        },
+      },
+      {
+        id: "time",
+        accessorFn: createAccessor("timezone"),
+        header: () => (
+          <>
+            <div>local time</div>
+            <div className="u-text--muted">timezone</div>
+          </>
+        ),
+        cell: ({ getValue }) => {
+          const { timezone } = getValue();
+          return timezone ? (
+            <>
+              <div>
+                {format(utcToZonedTime(new Date(), timezone), "HH:mm")} (local
+                time)
+              </div>
+              <div className="u-text--muted">{timezone}</div>
+            </>
+          ) : null;
+        },
+      },
+      {
+        id: "status",
+        accessorFn: createAccessor("stats"),
+        header: () => (
+          <>
+            <div>machines</div>
+            <div className="u-text--muted">aggregated status</div>
+          </>
+        ),
+        cell: ({ getValue }) => {
+          const { stats } = getValue();
+          const {
+            machines,
+            ready_machines,
+            occupied_machines,
+            error_machines,
+          } = stats || {};
+          return (
+            <>
+              <div>{machines}</div>
+              <div className="u-text--muted">
+                Ready: {ready_machines}, Occupied: {occupied_machines}, Error:{" "}
+                {error_machines}
+              </div>
+            </>
+          );
+        },
+      },
+    ],
+    []
+  );
+
+  const [rowSelection, setRowSelection] = useState({});
+
+  const table = useReactTable<Site>({
+    data: data || [],
+    columns,
+    state: {
+      rowSelection,
+      columnVisibility,
+    },
+    onColumnVisibilityChange: setColumnVisibility,
+    enableRowSelection: true,
+    onRowSelectionChange: setRowSelection,
+    enableColumnResizing: false,
+    columnResizeMode: "onChange",
+    getCoreRowModel: getCoreRowModel(),
+    getFilteredRowModel: getFilteredRowModel(),
+    getPaginationRowModel: getPaginationRowModel(),
+    debugTable: true,
+    debugHeaders: true,
+    debugColumns: true,
+  });
+
+  return (
+    <table className="u-table-layout--auto" aria-label="sites">
+      <thead>
+        {table.getHeaderGroups().map((headerGroup) => (
+          <tr key={headerGroup.id}>
+            {headerGroup.headers.map((header) => {
+              return (
+                <th key={header.id} colSpan={header.colSpan}>
+                  {header.isPlaceholder
+                    ? null
+                    : flexRender(
+                        header.column.columnDef.header,
+                        header.getContext()
+                      )}
+                  {header.column.getCanResize() && (
+                    <div
+                      onMouseDown={header.getResizeHandler()}
+                      onTouchStart={header.getResizeHandler()}
+                      className={`resizer ${
+                        header.column.getIsResizing() ? "isResizing" : ""
+                      }`}
+                    ></div>
+                  )}
+                </th>
+              );
+            })}
+          </tr>
+        ))}
+      </thead>
+      <tbody>
+        {table.getRowModel().rows.map((row) => {
+          return (
+            <tr key={row.id}>
+              {row.getVisibleCells().map((cell) => {
+                return (
+                  <td key={cell.id}>
+                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                  </td>
+                );
+              })}
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+};
+
+export default SitesTable;
diff --git a/frontend/src/components/SitesList/types.ts b/frontend/src/components/SitesList/types.ts
index c8495ac..e69de29 100644
--- a/frontend/src/components/SitesList/types.ts
+++ b/frontend/src/components/SitesList/types.ts
@@ -1,26 +0,0 @@
-export type Site = {
-  name: string;
-  url: string; // <full URL including protocol>,
-  connection: "stable" | "stale" | "lost";
-  last_seen: string; // <ISO 8601 date>,
-  address: {
-    countrycode: string; // <alpha2 country code>,
-    city: string;
-    zip: string;
-    street: string;
-  };
-  timezone: string; // <three letter abbreviation>,
-  stats: {
-    machines: number;
-    occupied_machines: number;
-    ready_machines: number;
-    error_machines: number;
-  };
-};
-
-export type Sites = {
-  items: Site[];
-  total: number;
-  page: number;
-  size: number;
-};
diff --git a/frontend/src/hooks/api.test.ts b/frontend/src/hooks/api.test.ts
new file mode 100644
index 0000000..46390b6
--- /dev/null
+++ b/frontend/src/hooks/api.test.ts
@@ -0,0 +1,27 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import urls from "../api/urls";
+import { sites } from "../mocks/factories";
+import { createMockGetServer } from "../mocks/server";
+import { Providers } from "../test-utils";
+import { useSitesQuery } from "./api";
+
+const sitesData = sites();
+const mockServer = createMockGetServer(urls.sites, sitesData);
+
+beforeAll(() => {
+  mockServer.listen();
+});
+afterEach(() => {
+  mockServer.resetHandlers();
+});
+afterAll(() => {
+  mockServer.close();
+});
+
+it("should return sites", async () => {
+  const { result } = renderHook(() => useSitesQuery(), { wrapper: Providers });
+
+  await waitFor(() => expect(result.current.isFetchedAfterMount).toBe(true));
+
+  expect(result.current.data!.items).toEqual(sitesData.items);
+});
diff --git a/frontend/src/hooks/api.ts b/frontend/src/hooks/api.ts
new file mode 100644
index 0000000..e70f848
--- /dev/null
+++ b/frontend/src/hooks/api.ts
@@ -0,0 +1,5 @@
+import { useQuery } from "react-query";
+import { getSites } from "../api/handlers";
+import { Sites } from "../api/types";
+
+export const useSitesQuery = () => useQuery<Sites>("/api/sites", getSites);
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index a8ae664..5d83917 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -3,7 +3,8 @@ import ReactDOM from "react-dom/client";
 import App from "./App";
 
 if (process.env.NODE_ENV === "development") {
-  import("./mocks/browser");
+  const { worker } = await import("./mocks/browser");
+  worker.start();
 }
 
 ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts
index 0f20911..34d0b39 100644
--- a/frontend/src/mocks/browser.ts
+++ b/frontend/src/mocks/browser.ts
@@ -1,11 +1,9 @@
-// src/mocks/browser.js
+import urls from "../api/urls";
 import { setupWorker, rest } from "msw";
 import { sites } from "./factories";
 
-const worker = setupWorker(
-  rest.get("/api/sites", (_req, res, ctx) => {
-    return res(ctx.json(sites));
+export const worker = setupWorker(
+  rest.get(urls.sites, (_req, res, ctx) => {
+    return res(ctx.json(sites()));
   })
 );
-
-worker.start();
diff --git a/frontend/src/mocks/factories.ts b/frontend/src/mocks/factories.ts
index 2e647e2..67bbc0b 100644
--- a/frontend/src/mocks/factories.ts
+++ b/frontend/src/mocks/factories.ts
@@ -1,4 +1,6 @@
-export const site = (site = {}) => ({
+import { Site } from "../api/types";
+
+export const site = (site: Partial<Site> = {}): Site => ({
   name: "maas-example-region",
   url: "http://maas.example.com";,
   connection: "stable",
@@ -19,7 +21,7 @@ export const site = (site = {}) => ({
   ...site,
 });
 
-export const sites = {
+export const sites = (sites = {}) => ({
   items: [
     site(),
     site({
@@ -37,4 +39,5 @@ export const sites = {
   total: 42,
   page: 1,
   size: 20,
-};
+  ...sites,
+});
diff --git a/frontend/src/mocks/server.ts b/frontend/src/mocks/server.ts
new file mode 100644
index 0000000..83178d5
--- /dev/null
+++ b/frontend/src/mocks/server.ts
@@ -0,0 +1,16 @@
+// src/mocks/browser.js
+import { setupServer } from "msw/node";
+import { rest } from "msw";
+import { sites } from "./factories";
+import urls from "../api/urls";
+
+const createMockGetServer = (endpoint: string, response: object) =>
+  setupServer(
+    rest.get(endpoint, (_req, res, ctx) => {
+      return res(ctx.json(response));
+    })
+  );
+
+const mockSitesServer = createMockGetServer(urls.sites, sites());
+
+export { createMockGetServer, mockSitesServer };
diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx
index 543ac8a..622c7ae 100644
--- a/frontend/src/test-utils.tsx
+++ b/frontend/src/test-utils.tsx
@@ -25,3 +25,4 @@ const customRender = (
 
 export * from "@testing-library/react";
 export { customRender as render };
+export { Providers };
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index f3fdf22..cde0616 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -2,10 +2,10 @@ import { defineConfig } from "vite";
 import react from "@vitejs/plugin-react-swc";
 import dotenv from "dotenv";
 
-dotenv.config();
+dotenv.config({ path: "../.env" });
 
 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [react()],
-  server: { port: Number(process.env.UI_PORT) },
+  server: { port: Number(process.env.VITE_UI_PORT) },
 });
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 8767b5f..2cd5a2c 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from "vitest/config";
+import { defineConfig, configDefaults } from "vitest/config";
 import react from "@vitejs/plugin-react-swc";
 
 export default defineConfig({
@@ -6,6 +6,7 @@ export default defineConfig({
   test: {
     globals: true,
     environment: "jsdom",
-    setupFiles: "./setupTests.js",
+    setupFiles: ["./setupTests.ts"],
+    exclude: [...configDefaults.exclude, "**/tests/**"],
   },
 });
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 959ae32..18f7148 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -45,6 +45,22 @@
   resolved "https://registry.yarnpkg.com/@canonical/latest-news/-/latest-news-1.4.1.tgz#dcdd445ac2268a54cf60f2f8c725b6bdeb285d71";
   integrity sha512-lwrikCj0Y11X8Ln8D+elp6Ri3mYjMjsAOJtadNEFzhBrgmg8TVGYJjOdwy8TvRaxy7fHnvVajIKAYWX44Tfj6w==
 
+"@canonical/react-components@0.38.0":
+  version "0.38.0"
+  resolved "https://registry.yarnpkg.com/@canonical/react-components/-/react-components-0.38.0.tgz#180eb0412d62e29002a724386d08d5bea959b58b";
+  integrity sha512-0t20yrHamxCPCxlJu7ZjXMFexrfZXUSTZvVL+dSoTF8ZHTTi/NUnvgLMak8NWzVht9nOkVb6ltAA8pPSmMr8rg==
+  dependencies:
+    "@types/jest" "27.5.2"
+    "@types/node" "16.11.47"
+    "@types/react" "17.0.48"
+    "@types/react-dom" "17.0.17"
+    "@types/react-table" "7.7.12"
+    classnames "2.3.1"
+    nanoid "3.3.4"
+    prop-types "15.8.1"
+    react-table "7.8.0"
+    react-useportal "1.0.17"
+
 "@esbuild/android-arm64@0.16.17":
   version "0.16.17"
   resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz#cf91e86df127aa3d141744edafcba0abdc577d23";
@@ -229,7 +245,7 @@
   resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca";
   integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q==
 
-"@playwright/test@^1.31.1":
+"@playwright/test@1.31.1":
   version "1.31.1"
   resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.31.1.tgz#39d6873dc46af135f12451d79707db7d1357455d";
   integrity sha512-IsytVZ+0QLDh1Hj83XatGp/GsI1CDJWbyDaBGbainsh0p2zC7F4toUocqowmjS6sQff2NGT3D9WbDj/3K2CJiA==
@@ -315,6 +331,18 @@
     "@swc/core-win32-ia32-msvc" "1.3.35"
     "@swc/core-win32-x64-msvc" "1.3.35"
 
+"@tanstack/react-table@8.7.9":
+  version "8.7.9"
+  resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.7.9.tgz#9efcd168fb0080a7e0bc213b5eac8b55513babf4";
+  integrity sha512-6MbbQn5AupSOkek1+6IYu+1yZNthAKTRZw9tW92Vi6++iRrD1GbI3lKTjJalf8lEEKOqapPzQPE20nywu0PjCA==
+  dependencies:
+    "@tanstack/table-core" "8.7.9"
+
+"@tanstack/table-core@8.7.9":
+  version "8.7.9"
+  resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.7.9.tgz#0e975f8a5079972f1827a569079943d43257c42f";
+  integrity sha512-4RkayPMV1oS2SKDXfQbFoct1w5k+pvGpmX18tCXMofK/VDRdA2hhxfsQlMvsJ4oTX8b0CI4Y3GDKn5T425jBCw==
+
 "@testing-library/dom@^8.5.0":
   version "8.20.0"
   resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.0.tgz#914aa862cef0f5e89b98cc48e3445c4c921010f6";
@@ -358,6 +386,13 @@
   resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc";
   integrity sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==
 
+"@types/axios@0.14.0":
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46";
+  integrity sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ==
+  dependencies:
+    axios "*"
+
 "@types/chai-subset@^1.3.3":
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94";
@@ -409,11 +444,24 @@
     expect "^29.0.0"
     pretty-format "^29.0.0"
 
+"@types/jest@27.5.2":
+  version "27.5.2"
+  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.5.2.tgz#ec49d29d926500ffb9fd22b84262e862049c026c";
+  integrity sha512-mpT8LJJ4CMeeahobofYWIjFo0xonRS/HfxnVEPMPFSQdGUt1uHCnoPT7Zhb+sjDU2wz0oKV0OLUR0WzrHNgfeA==
+  dependencies:
+    jest-matcher-utils "^27.0.0"
+    pretty-format "^27.0.0"
+
 "@types/js-levenshtein@^1.1.1":
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/@types/js-levenshtein/-/js-levenshtein-1.1.1.tgz#ba05426a43f9e4e30b631941e0aa17bf0c890ed5";
   integrity sha512-qC4bCqYGy1y/NP7dDVr7KJarn+PbX1nSpwA7JXdu0HxT3QYjO8MJ+cntENtHFVy2dRAyBV23OZ6MxsW1AM1L8g==
 
+"@types/lodash@4.14.191":
+  version "4.14.191"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa";
+  integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
+
 "@types/ms@*":
   version "0.7.31"
   resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197";
@@ -424,11 +472,23 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.13.0.tgz#0400d1e6ce87e9d3032c19eb6c58205b0d3f7850";
   integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==
 
+"@types/node@16.11.47":
+  version "16.11.47"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.47.tgz#efa9e3e0f72e7aa6a138055dace7437a83d9f91c";
+  integrity sha512-fpP+jk2zJ4VW66+wAMFoBJlx1bxmBKx4DUFf68UHgdGCOuyUTDlLWqsaNPJh7xhNDykyJ9eIzAygilP/4WoN8g==
+
 "@types/prop-types@*":
   version "15.7.5"
   resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf";
   integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
 
+"@types/react-dom@17.0.17":
+  version "17.0.17"
+  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.17.tgz#2e3743277a793a96a99f1bf87614598289da68a1";
+  integrity sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg==
+  dependencies:
+    "@types/react" "^17"
+
 "@types/react-dom@18.0.10":
   version "18.0.10"
   resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.10.tgz#3b66dec56aa0f16a6cc26da9e9ca96c35c0b4352";
@@ -443,6 +503,13 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-table@7.7.12":
+  version "7.7.12"
+  resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-7.7.12.tgz#628011d3cb695b07c678704a61f2f1d5b8e567fd";
+  integrity sha512-bRUent+NR/WwtDGwI/BqhZ8XnHghwHw0HUKeohzB5xN3K2qKWYE5w19e7GCuOkL1CXD9Gi1HFy7TIm2AvgWUHg==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react@*":
   version "18.0.28"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.28.tgz#accaeb8b86f4908057ad629a26635fe641480065";
@@ -452,6 +519,15 @@
     "@types/scheduler" "*"
     csstype "^3.0.2"
 
+"@types/react@17.0.48":
+  version "17.0.48"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.48.tgz#a4532a8b91d7b27b8768b6fc0c3bccb760d15a6c";
+  integrity sha512-zJ6IYlJ8cYYxiJfUaZOQee4lh99mFihBoqkOSEGV+dFi9leROW6+PgstzQ+w3gWTnUfskALtQPGHK6dYmPj+2A==
+  dependencies:
+    "@types/prop-types" "*"
+    "@types/scheduler" "*"
+    csstype "^3.0.2"
+
 "@types/react@18.0.27":
   version "18.0.27"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.27.tgz#d9425abe187a00f8a5ec182b010d4fd9da703b71";
@@ -461,6 +537,15 @@
     "@types/scheduler" "*"
     csstype "^3.0.2"
 
+"@types/react@^17":
+  version "17.0.53"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.53.tgz#10d4d5999b8af3d6bc6a9369d7eb953da82442ab";
+  integrity sha512-1yIpQR2zdYu1Z/dc1OxC+MA6GR240u3gcnP4l6mvj/PJiVaqHsQPmWttsvHsfnhfPbU2FuGmo0wSITPygjBmsw==
+  dependencies:
+    "@types/prop-types" "*"
+    "@types/scheduler" "*"
+    csstype "^3.0.2"
+
 "@types/scheduler@*":
   version "0.16.2"
   resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39";
@@ -626,6 +711,11 @@ assertion-error@^1.1.0:
   resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b";
   integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
 
+asynckit@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79";
+  integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
+
 autoprefixer@10.4.13:
   version "10.4.13"
   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.13.tgz#b5136b59930209a321e9fa3dca2e7c4d223e83a8";
@@ -643,6 +733,15 @@ available-typed-arrays@^1.0.5:
   resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7";
   integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==
 
+axios@*, axios@1.3.4:
+  version "1.3.4"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-1.3.4.tgz#f5760cefd9cfb51fd2481acf88c05f67c4523024";
+  integrity sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==
+  dependencies:
+    follow-redirects "^1.15.0"
+    form-data "^4.0.0"
+    proxy-from-env "^1.1.0"
+
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee";
@@ -818,6 +917,11 @@ ci-info@^3.2.0:
   resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91";
   integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==
 
+classnames@2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e";
+  integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
+
 cli-cursor@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307";
@@ -881,6 +985,13 @@ color-name@~1.1.4:
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2";
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
+combined-stream@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f";
+  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
+  dependencies:
+    delayed-stream "~1.0.0"
+
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b";
@@ -901,6 +1012,16 @@ csstype@^3.0.2:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9";
   integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
 
+date-fns-tz@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/date-fns-tz/-/date-fns-tz-2.0.0.tgz#1b14c386cb8bc16fc56fe333d4fc34ae1d1099d5";
+  integrity sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==
+
+date-fns@2.29.3:
+  version "2.29.3"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8";
+  integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==
+
 debug@^4.3.3, debug@^4.3.4:
   version "4.3.4"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865";
@@ -953,6 +1074,11 @@ define-properties@^1.1.3, define-properties@^1.1.4:
     has-property-descriptors "^1.0.0"
     object-keys "^1.1.1"
 
+delayed-stream@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619";
+  integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
+
 dependency-graph@^0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/dependency-graph/-/dependency-graph-0.11.0.tgz#ac0ce7ed68a54da22165a85e97a01d53f5eb2e27";
@@ -963,6 +1089,11 @@ detect-node@^2.0.4, detect-node@^2.1.0:
   resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1";
   integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
 
+diff-sequences@^27.5.1:
+  version "27.5.1"
+  resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327";
+  integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==
+
 diff-sequences@^29.4.3:
   version "29.4.3"
   resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2";
@@ -985,7 +1116,7 @@ dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9:
   resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453";
   integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==
 
-dotenv@^16.0.3:
+dotenv@16.0.3:
   version "16.0.3"
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07";
   integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==
@@ -1125,6 +1256,11 @@ fill-range@^7.0.1:
   dependencies:
     to-regex-range "^5.0.1"
 
+follow-redirects@^1.15.0:
+  version "1.15.2"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13";
+  integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==
+
 for-each@^0.3.3:
   version "0.3.3"
   resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e";
@@ -1132,6 +1268,15 @@ for-each@^0.3.3:
   dependencies:
     is-callable "^1.1.3"
 
+form-data@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452";
+  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.8"
+    mime-types "^2.1.12"
+
 fraction.js@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950";
@@ -1536,6 +1681,16 @@ isarray@^2.0.5:
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723";
   integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
 
+jest-diff@^27.5.1:
+  version "27.5.1"
+  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def";
+  integrity sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==
+  dependencies:
+    chalk "^4.0.0"
+    diff-sequences "^27.5.1"
+    jest-get-type "^27.5.1"
+    pretty-format "^27.5.1"
+
 jest-diff@^29.4.3:
   version "29.4.3"
   resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.4.3.tgz#42f4eb34d0bf8c0fb08b0501069b87e8e84df347";
@@ -1546,11 +1701,26 @@ jest-diff@^29.4.3:
     jest-get-type "^29.4.3"
     pretty-format "^29.4.3"
 
+jest-get-type@^27.5.1:
+  version "27.5.1"
+  resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1";
+  integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==
+
 jest-get-type@^29.4.3:
   version "29.4.3"
   resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5";
   integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg==
 
+jest-matcher-utils@^27.0.0:
+  version "27.5.1"
+  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz#9c0cdbda8245bc22d2331729d1091308b40cf8ab";
+  integrity sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==
+  dependencies:
+    chalk "^4.0.0"
+    jest-diff "^27.5.1"
+    jest-get-type "^27.5.1"
+    pretty-format "^27.5.1"
+
 jest-matcher-utils@^29.4.3:
   version "29.4.3"
   resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.4.3.tgz#ea68ebc0568aebea4c4213b99f169ff786df96a0";
@@ -1627,7 +1797,7 @@ local-pkg@^0.4.2:
   resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963";
   integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==
 
-lodash@^4.17.15, lodash@^4.17.21:
+lodash@4.17.21, lodash@^4.17.15, lodash@^4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c";
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -1640,7 +1810,7 @@ log-symbols@^4.1.0:
     chalk "^4.1.0"
     is-unicode-supported "^0.1.0"
 
-loose-envify@^1.1.0:
+loose-envify@^1.1.0, loose-envify@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf";
   integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -1685,6 +1855,18 @@ microseconds@0.2.0:
   resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39";
   integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==
 
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70";
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.12:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a";
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
 mimic-fn@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b";
@@ -1712,6 +1894,11 @@ mlly@^1.0.0, mlly@^1.1.0:
     pkg-types "^1.0.1"
     ufo "^1.0.1"
 
+mockdate@3.0.5:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/mockdate/-/mockdate-3.0.5.tgz#789be686deb3149e7df2b663d2bc4392bc3284fb";
+  integrity sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==
+
 ms@2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009";
@@ -1754,7 +1941,7 @@ nano-time@1.0.0:
   dependencies:
     big-integer "^1.6.16"
 
-nanoid@^3.3.4:
+nanoid@3.3.4, nanoid@^3.3.4:
   version "3.3.4"
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab";
   integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
@@ -1781,6 +1968,11 @@ normalize-range@^0.1.2:
   resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942";
   integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
 
+object-assign@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863";
+  integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
+
 object-inspect@^1.9.0:
   version "1.12.3"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9";
@@ -1972,7 +2164,7 @@ postcss@8.4.21, postcss@^8.4.21:
     picocolors "^1.0.0"
     source-map-js "^1.0.2"
 
-pretty-format@^27.0.2, pretty-format@^27.5.1:
+pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.5.1:
   version "27.5.1"
   resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e";
   integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==
@@ -1995,6 +2187,20 @@ pretty-hrtime@^1.0.3:
   resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1";
   integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==
 
+prop-types@15.8.1:
+  version "15.8.1"
+  resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5";
+  integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
+  dependencies:
+    loose-envify "^1.4.0"
+    object-assign "^4.1.1"
+    react-is "^16.13.1"
+
+proxy-from-env@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2";
+  integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
 queue-microtask@^1.2.2:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243";
@@ -2008,6 +2214,11 @@ react-dom@18.2.0:
     loose-envify "^1.1.0"
     scheduler "^0.23.0"
 
+react-is@^16.13.1:
+  version "16.13.1"
+  resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4";
+  integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
+
 react-is@^17.0.1:
   version "17.0.2"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0";
@@ -2042,6 +2253,18 @@ react-router@6.8.1:
   dependencies:
     "@remix-run/router" "1.3.2"
 
+react-table@7.8.0:
+  version "7.8.0"
+  resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2";
+  integrity sha512-hNaz4ygkZO4bESeFfnfOft73iBUj8K5oKi1EcSHPAibEydfsX2MyU6Z8KCr3mv3C9Kqqh71U+DhZkFvibbnPbA==
+
+react-useportal@1.0.17:
+  version "1.0.17"
+  resolved "https://registry.yarnpkg.com/react-useportal/-/react-useportal-1.0.17.tgz#dcea1de8aa6d1ebd4bb3bb08075180a0e620f718";
+  integrity sha512-IS1NC+qQtp8e9Z9f8EPpW5whU85VuaT9Vxzw20vAAfKoRB9dlsnFmZ8NeNkbjNaue7Iy64Qsafs73M7qvkMzoA==
+  dependencies:
+    use-ssr "^1.0.22"
+
 react@18.2.0:
   version "18.2.0"
   resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5";
@@ -2372,6 +2595,11 @@ through@^2.3.6:
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5";
   integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
 
+timezone-mock@1.3.6:
+  version "1.3.6"
+  resolved "https://registry.yarnpkg.com/timezone-mock/-/timezone-mock-1.3.6.tgz#44e4c5aeb57e6c07ae630a05c528fc4d9aab86f4";
+  integrity sha512-YcloWmZfLD9Li5m2VcobkCDNVaLMx8ohAb/97l/wYS3m+0TIEK5PFNMZZfRcusc6sFjIfxu8qcJT0CNnOdpqmg==
+
 tinybench@^2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.3.1.tgz#14f64e6b77d7ef0b1f6ab850c7a808c6760b414d";
@@ -2457,6 +2685,11 @@ update-browserslist-db@^1.0.10:
     escalade "^3.1.1"
     picocolors "^1.0.0"
 
+use-ssr@^1.0.22:
+  version "1.0.24"
+  resolved "https://registry.yarnpkg.com/use-ssr/-/use-ssr-1.0.24.tgz#213a3df58f5ab9268e6fe1a57ad0a9de91e514d1";
+  integrity sha512-0MFps7ezL57/3o0yl4CvrHLlp9z20n1rQZV/lSRz7if+TUoM6POU1XdOvEjIgjgKeIhTEye1U0khrIYWCTWw4g==
+
 util-deprecate@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf";

Follow ups