Peter Makowski has proposed merging 
~petermakowski/maas-site-manager:MAASENG-1509-add-requests-table into 
maas-site-manager:main.

Commit message:
add enrollment requests table  MAASENG-1509
- Add DateTime, ExternalLink, TableCaption components
- use inline checkbox styles for table inputs
- add missing styles for form validation
- add react-error-boundary

Requested reviews:
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/439925

QA Steps

- Go to /requests
- Verify that the enrollment requests table is displayed and the first row 
displays "Invalid Time Value"
-- 
Your team MAAS Committers is requested to review the proposed merge of 
~petermakowski/maas-site-manager:MAASENG-1509-add-requests-table into 
maas-site-manager:main.
diff --git a/frontend/package.json b/frontend/package.json
index 73901ff..db751a9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -26,6 +26,7 @@
     "pluralize": "8.0.0",
     "react": "18.2.0",
     "react-dom": "18.2.0",
+    "react-error-boundary": "4.0.3",
     "react-router-dom": "6.9.0",
     "use-local-storage-state": "18.2.1",
     "vanilla-framework": "3.12.1",
diff --git a/frontend/src/App.scss b/frontend/src/App.scss
index 2f94fa1..9be4d17 100644
--- a/frontend/src/App.scss
+++ b/frontend/src/App.scss
@@ -17,6 +17,9 @@
 @include vf-p-search-box;
 @include vf-p-form-tick-elements;
 @include vf-p-tooltips;
+@include vf-p-strip;
+@include vf-p-contextual-menu;
+@include vf-p-form-validation;
 
 // icons
 @include vf-p-icons;
@@ -33,6 +36,7 @@
 @include vf-u-vertical-spacing;
 @include vf-u-vertically-center;
 @include vf-u-hide;
+@include vf-u-align;
 
 // layout
 @include vf-l-application;
diff --git a/frontend/src/api/handlers.ts b/frontend/src/api/handlers.ts
index 5fd33e6..5a33c6f 100644
--- a/frontend/src/api/handlers.ts
+++ b/frontend/src/api/handlers.ts
@@ -7,8 +7,8 @@ export type PaginationParams = {
   page: string;
   size: string;
 };
-export type GetSitesQueryParams = PaginationParams & {};
 
+export type GetSitesQueryParams = PaginationParams & {};
 export const getSites = async (params: GetSitesQueryParams, queryText?: string) => {
   try {
     const response = await api.get(urls.sites, {
@@ -29,7 +29,6 @@ export type PostTokensData = {
   name?: string;
   expires: string; // <ISO 8601 date string>,
 };
-
 export const postTokens = async (data: PostTokensData) => {
   if (!data?.amount || !data?.expires) {
     throw Error("Missing required fields");
@@ -43,7 +42,6 @@ export const postTokens = async (data: PostTokensData) => {
 };
 
 export type GetTokensQueryParams = PaginationParams & {};
-
 export const getTokens = async (params: GetTokensQueryParams) => {
   try {
     const response = await api.get(urls.tokens, { params });
@@ -53,3 +51,14 @@ export const getTokens = async (params: GetTokensQueryParams) => {
     console.error(error);
   }
 };
+
+export type GetEnrollmentRequestsQueryParams = PaginationParams & {};
+export const getEnrollmentRequests = async (params: GetEnrollmentRequestsQueryParams) => {
+  try {
+    const response = await api.get(urls.enrollmentRequests, { params });
+    return response.data;
+  } catch (error) {
+    // eslint-disable-next-line no-console
+    console.error(error);
+  }
+};
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 8166a68..0bede7d 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -19,16 +19,14 @@ export type Site = {
   };
 };
 
-export type PaginatedQueryResult = {
-  items: unknown[];
+export type PaginatedQueryResult<D extends unknown> = {
+  items: D[];
   total: number;
   page: number;
   size: number;
 };
 
-export type SitesQueryResult = PaginatedQueryResult & {
-  items: Site[];
-};
+export type SitesQueryResult = PaginatedQueryResult<Site>;
 
 export type Token = {
   name: string;
@@ -39,3 +37,12 @@ export type Token = {
 export type PostTokensResult = {
   items: Token[];
 };
+
+export type EnrollmentRequest = {
+  id: string;
+  name: string;
+  url: string;
+  created: string; // <ISO 8601 date>,
+};
+
+export type EnrollmentRequestsQueryResult = PaginatedQueryResult<EnrollmentRequest>;
diff --git a/frontend/src/api/urls.ts b/frontend/src/api/urls.ts
index 5d2fe6e..a588d4e 100644
--- a/frontend/src/api/urls.ts
+++ b/frontend/src/api/urls.ts
@@ -3,6 +3,7 @@ import { getApiUrl } from "./utils";
 const urls = {
   sites: getApiUrl("/sites"),
   tokens: getApiUrl("/tokens"),
+  enrollmentRequests: getApiUrl("/requests"),
 };
 
 export default urls;
diff --git a/frontend/src/base/docsUrls.ts b/frontend/src/base/docsUrls.ts
new file mode 100644
index 0000000..16a46db
--- /dev/null
+++ b/frontend/src/base/docsUrls.ts
@@ -0,0 +1,5 @@
+const docsUrls = {
+  enrollmentRequest: "",
+};
+
+export default docsUrls;
diff --git a/frontend/src/components/DateTime/DateTime.test.tsx b/frontend/src/components/DateTime/DateTime.test.tsx
new file mode 100644
index 0000000..142f172
--- /dev/null
+++ b/frontend/src/components/DateTime/DateTime.test.tsx
@@ -0,0 +1,13 @@
+import DateTime from "./DateTime";
+
+import { render, screen } from "@/test-utils";
+
+it("renders time in a correct format", () => {
+  render(<DateTime value="2023-01-23T09:36:01.064Z" />);
+  expect(screen.getByText("2023-01-23 10:01")).toBeInTheDocument();
+});
+
+it("renders invalid time fallback value correctly", () => {
+  render(<DateTime value="" />);
+  expect(screen.getByText("Invalid time value")).toBeInTheDocument();
+});
diff --git a/frontend/src/components/DateTime/DateTime.tsx b/frontend/src/components/DateTime/DateTime.tsx
new file mode 100644
index 0000000..37c7a5a
--- /dev/null
+++ b/frontend/src/components/DateTime/DateTime.tsx
@@ -0,0 +1,11 @@
+import { withErrorBoundary } from "react-error-boundary";
+
+import { formatUTCDateString } from "@/utils";
+
+const DateTime = ({ value }: { value: string }) => <time dateTime={value}>{formatUTCDateString(value)}</time>;
+
+const DateTimeWithErrorBoundary = withErrorBoundary(DateTime, {
+  fallback: <div>Invalid time value</div>,
+});
+
+export default DateTimeWithErrorBoundary;
diff --git a/frontend/src/components/DateTime/index.ts b/frontend/src/components/DateTime/index.ts
new file mode 100644
index 0000000..3f2a0d7
--- /dev/null
+++ b/frontend/src/components/DateTime/index.ts
@@ -0,0 +1 @@
+export { default } from "./DateTime";
diff --git a/frontend/src/components/ExternalLink/ExternalLink.tsx b/frontend/src/components/ExternalLink/ExternalLink.tsx
new file mode 100644
index 0000000..4c3c869
--- /dev/null
+++ b/frontend/src/components/ExternalLink/ExternalLink.tsx
@@ -0,0 +1,10 @@
+import type { LinkProps } from "react-router-dom";
+import { Link } from "react-router-dom";
+
+const ExternalLink = ({ to, children }: LinkProps) => (
+  <Link rel="noreferrer noopener" target="_blank" to={to}>
+    {children}
+  </Link>
+);
+
+export default ExternalLink;
diff --git a/frontend/src/components/ExternalLink/index.ts b/frontend/src/components/ExternalLink/index.ts
new file mode 100644
index 0000000..32dc9d3
--- /dev/null
+++ b/frontend/src/components/ExternalLink/index.ts
@@ -0,0 +1 @@
+export { default } from "./ExternalLink";
diff --git a/frontend/src/components/RequestsTable/RequestsTable.test.tsx b/frontend/src/components/RequestsTable/RequestsTable.test.tsx
new file mode 100644
index 0000000..1d2ff3e
--- /dev/null
+++ b/frontend/src/components/RequestsTable/RequestsTable.test.tsx
@@ -0,0 +1,60 @@
+import RequestsTable from "./RequestsTable";
+
+import { enrollmentRequestFactory, enrollmentRequestQueryResultFactory } from "@/mocks/factories";
+import { renderWithMemoryRouter, screen, within } from "@/test-utils";
+import { formatUTCDateString } from "@/utils";
+
+it("displays a loading text", () => {
+  const { rerender } = renderWithMemoryRouter(
+    <RequestsTable data={enrollmentRequestQueryResultFactory.build()} isFetchedAfterMount={false} isLoading={true} />,
+  );
+
+  const table = screen.getByRole("table", { name: /enrollment requests/i });
+  expect(table).toBeInTheDocument();
+  expect(within(table).getByText(/Loading/i)).toBeInTheDocument();
+
+  rerender(
+    <RequestsTable data={enrollmentRequestQueryResultFactory.build()} isFetchedAfterMount={true} isLoading={false} />,
+  );
+
+  expect(within(table).queryByText(/Loading/i)).not.toBeInTheDocument();
+});
+
+it("should show a message if there are no open enrolment requests", () => {
+  renderWithMemoryRouter(
+    <RequestsTable data={enrollmentRequestQueryResultFactory.build()} isFetchedAfterMount={true} isLoading={false} />,
+  );
+
+  const table = screen.getByRole("table", { name: /enrollment requests/i });
+  expect(table).toBeInTheDocument();
+  expect(within(table).getByText(/No outstanding requests/i)).toBeInTheDocument();
+});
+
+it("displays enrollment request in each table row correctly", () => {
+  const items = enrollmentRequestFactory.buildList(1);
+  renderWithMemoryRouter(
+    <RequestsTable
+      data={enrollmentRequestQueryResultFactory.build({ items })}
+      isFetchedAfterMount={true}
+      isLoading={false}
+    />,
+  );
+
+  const tableBody = screen.getAllByRole("rowgroup")[1];
+  const tableRows = within(tableBody).getAllByRole("row");
+
+  expect(tableRows).toHaveLength(items.length);
+
+  tableRows.forEach((row, i) => {
+    const checkbox = new RegExp(`select ${items[i].name}`, "i");
+    const name = items[i].name;
+    const url = new RegExp(items[i].url, "i");
+    const timeOfRequest = new RegExp(formatUTCDateString(items[i].created), "i");
+    const expectedCells = [checkbox, name, url, timeOfRequest];
+
+    expect(within(row).getAllByRole("cell")).toHaveLength(expectedCells.length);
+    expectedCells.forEach((cell) => {
+      expect(within(row).getByRole("cell", { name: cell })).toBeInTheDocument();
+    });
+  });
+});
diff --git a/frontend/src/components/RequestsTable/RequestsTable.tsx b/frontend/src/components/RequestsTable/RequestsTable.tsx
new file mode 100644
index 0000000..0b88760
--- /dev/null
+++ b/frontend/src/components/RequestsTable/RequestsTable.tsx
@@ -0,0 +1,159 @@
+import { useEffect, useMemo } from "react";
+
+import { useReactTable, flexRender, getCoreRowModel, createColumnHelper } from "@tanstack/react-table";
+import type { Column, Getter, Row, ColumnDef } from "@tanstack/react-table";
+
+import type { EnrollmentRequest } from "@/api/types";
+import docsUrls from "@/base/docsUrls";
+import DateTime from "@/components/DateTime";
+import ExternalLink from "@/components/ExternalLink";
+import TableCaption from "@/components/TableCaption";
+import { isDev } from "@/constants";
+import { useAppContext } from "@/context";
+import type { UseEnrollmentRequestsQueryResult } from "@/hooks/api";
+
+export type EnrollmentRequestsColumnDef = ColumnDef<EnrollmentRequest, EnrollmentRequest[keyof EnrollmentRequest]>;
+export type EnrollmentRequestsColumn = Column<EnrollmentRequest, unknown>;
+
+const columnHelper = createColumnHelper<EnrollmentRequest>();
+
+const RequestsTable = ({
+  data,
+  isFetchedAfterMount,
+  isLoading,
+}: Pick<UseEnrollmentRequestsQueryResult, "data" | "isLoading" | "isFetchedAfterMount">) => {
+  const { rowSelection, setRowSelection } = useAppContext();
+
+  // clear selection on unmount
+  useEffect(() => {
+    return () => setRowSelection({});
+  }, [setRowSelection]);
+
+  const columns = useMemo<EnrollmentRequestsColumnDef[]>(
+    () => [
+      {
+        id: "select",
+        accessorKey: "name",
+        header: ({ table }) => (
+          <label className="p-checkbox--inline">
+            <input
+              aria-checked={table.getIsSomeRowsSelected() || table.getIsSomePageRowsSelected() ? "mixed" : undefined}
+              aria-label="select all"
+              className="p-checkbox__input"
+              type="checkbox"
+              {...{
+                checked:
+                  table.getIsSomePageRowsSelected() ||
+                  table.getIsSomeRowsSelected() ||
+                  table.getIsAllPageRowsSelected(),
+                onChange: table.getToggleAllPageRowsSelectedHandler(),
+              }}
+            />
+            <span className="p-checkbox__label" />
+          </label>
+        ),
+        cell: ({ row, getValue }: { row: Row<EnrollmentRequest>; getValue: Getter<EnrollmentRequest["name"]> }) => {
+          return (
+            <label className="p-checkbox--inline">
+              <input
+                aria-label={`select ${getValue()}`}
+                className="p-checkbox__input"
+                type="checkbox"
+                {...{
+                  checked: row.getIsSelected(),
+                  disabled: !row.getCanSelect(),
+                  onChange: row.getToggleSelectedHandler(),
+                }}
+              />
+              <span className="p-checkbox__label" />
+            </label>
+          );
+        },
+      },
+      columnHelper.accessor("name", {
+        id: "name",
+        header: () => <div>Name</div>,
+      }),
+      columnHelper.accessor("url", {
+        id: "url",
+        header: () => <div>URL</div>,
+        cell: ({ getValue }) => <ExternalLink to={getValue()}>{getValue()}</ExternalLink>,
+      }),
+      columnHelper.accessor("created", {
+        id: "created",
+        header: () => <div>Time of request (UTC)</div>,
+        cell: ({ getValue }) => <DateTime value={getValue()} />,
+      }),
+    ],
+    [],
+  );
+
+  // wrap the empty array in useMemo to avoid re-rendering the empty table on every render
+  const noItems = useMemo<EnrollmentRequest[]>(() => [], []);
+  const table = useReactTable<EnrollmentRequest>({
+    data: data?.items || noItems,
+    columns,
+    state: {
+      rowSelection,
+    },
+    getRowId: (row) => row.id,
+    manualPagination: true,
+    enableRowSelection: true,
+    enableMultiRowSelection: true,
+    onRowSelectionChange: setRowSelection,
+    getCoreRowModel: getCoreRowModel(),
+    debugTable: isDev,
+    debugHeaders: isDev,
+    debugColumns: isDev,
+  });
+
+  return (
+    <>
+      <table aria-label="enrollment requests" className="sites-table">
+        <thead>
+          {table.getHeaderGroups().map((headerGroup) => (
+            <tr key={headerGroup.id}>
+              {headerGroup.headers.map((header) => {
+                return (
+                  <th className={`${header.column.id}`} colSpan={header.colSpan} key={header.id}>
+                    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
+                  </th>
+                );
+              })}
+            </tr>
+          ))}
+        </thead>
+        {isLoading && !isFetchedAfterMount ? (
+          <caption>Loading...</caption>
+        ) : table.getRowModel().rows.length < 1 ? (
+          <TableCaption>
+            <TableCaption.Title>No outstanding requests</TableCaption.Title>
+            <TableCaption.Description>
+              You have to request an enrolment in the site-manager-agent.
+              <br />
+              <ExternalLink to={docsUrls.enrollmentRequest}>Read more about it in the documentation.</ExternalLink>
+            </TableCaption.Description>
+          </TableCaption>
+        ) : (
+          <tbody>
+            {table.getRowModel().rows.map((row) => {
+              return (
+                <tr key={row.id}>
+                  {row.getVisibleCells().map((cell) => {
+                    return (
+                      <td className={`${cell.column.id}`} key={cell.id}>
+                        {flexRender(cell.column.columnDef.cell, cell.getContext())}
+                      </td>
+                    );
+                  })}
+                </tr>
+              );
+            })}
+          </tbody>
+        )}
+      </table>
+    </>
+  );
+};
+
+export default RequestsTable;
diff --git a/frontend/src/components/RequestsTable/index.ts b/frontend/src/components/RequestsTable/index.ts
new file mode 100644
index 0000000..e0a1689
--- /dev/null
+++ b/frontend/src/components/RequestsTable/index.ts
@@ -0,0 +1 @@
+export { default } from "./RequestsTable";
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
index 4b4a7e2..613acee 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
@@ -3,7 +3,7 @@ import { vi } from "vitest";
 
 import SitesTable from "./SitesTable";
 
-import { siteFactory } from "@/mocks/factories";
+import { siteFactory, sitesQueryResultFactory } from "@/mocks/factories";
 import { render, screen, within } from "@/test-utils";
 
 beforeEach(() => {
@@ -19,7 +19,7 @@ afterEach(() => {
 it("displays an empty sites table", () => {
   render(
     <SitesTable
-      data={{ items: [], total: 0, page: 1, size: 0 }}
+      data={sitesQueryResultFactory.build()}
       isFetchedAfterMount={true}
       isLoading={false}
       setSearchText={() => {}}
@@ -33,7 +33,7 @@ it("displays rows with details for each site", () => {
   const items = siteFactory.buildList(1);
   render(
     <SitesTable
-      data={{ items, total: 1, page: 1, size: 1 }}
+      data={sitesQueryResultFactory.build({ items, total: 1, page: 1, size: 1 })}
       isFetchedAfterMount={true}
       isLoading={false}
       setSearchText={() => {}}
@@ -54,7 +54,7 @@ it("displays correctly paginated results", () => {
   const items = siteFactory.buildList(pageLength);
   render(
     <SitesTable
-      data={{ items, total: 100, page: 1, size: pageLength }}
+      data={sitesQueryResultFactory.build({ items, total: 100, page: 1, size: pageLength })}
       isFetchedAfterMount={true}
       isLoading={false}
       setSearchText={() => {}}
@@ -72,7 +72,7 @@ it("displays correct local time", () => {
   const item = siteFactory.build({ timezone: 1 });
   render(
     <SitesTable
-      data={{ items: [item], total: 1, page: 1, size: 1 }}
+      data={sitesQueryResultFactory.build({ items: [item], total: 1, page: 1, size: 1 })}
       isFetchedAfterMount={true}
       isLoading={false}
       setSearchText={() => {}}
@@ -87,7 +87,7 @@ it("displays full name of the country", () => {
   const item = siteFactory.build({ address: { countrycode: "GB" } });
   render(
     <SitesTable
-      data={{ items: [item], total: 1, page: 1, size: 1 }}
+      data={sitesQueryResultFactory.build({ items: [item], total: 1, page: 1, size: 1 })}
       isFetchedAfterMount={true}
       isLoading={false}
       setSearchText={() => {}}
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
index 976cfdd..5b9b1b4 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
@@ -48,7 +48,7 @@ const SitesTable = ({
         id: "select",
         accessorKey: "name",
         header: ({ table }) => (
-          <label className="p-checkbox">
+          <label className="p-checkbox--inline">
             <input
               aria-checked={table.getIsSomeRowsSelected() || table.getIsSomePageRowsSelected() ? "mixed" : undefined}
               aria-label="select all"
@@ -67,7 +67,7 @@ const SitesTable = ({
         ),
         cell: ({ row, getValue }: { row: Row<Site>; getValue: Getter<Site["name"]> }) => {
           return (
-            <label className="p-checkbox">
+            <label className="p-checkbox--inline">
               <input
                 aria-label={getValue()}
                 className="p-checkbox__input"
diff --git a/frontend/src/components/TableCaption/TableCaption.tsx b/frontend/src/components/TableCaption/TableCaption.tsx
new file mode 100644
index 0000000..5a72f59
--- /dev/null
+++ b/frontend/src/components/TableCaption/TableCaption.tsx
@@ -0,0 +1,26 @@
+const TableCaption = ({ children }: { children: React.ReactNode }) => (
+  <caption>
+    <div className="p-strip">{children}</div>
+  </caption>
+);
+
+const Title = ({ children }: { children: React.ReactNode }) => (
+  <div className="row">
+    <div className="col-start-large-4 u-align--left col-8 col-medium-4 col-small-3">
+      <p className="p-heading--4 u-no-margin--bottom">{children}</p>
+    </div>
+  </div>
+);
+
+const Description = ({ children }: { children: React.ReactNode }) => (
+  <div className="row">
+    <div className="u-align--left col-start-large-4 col-8 col-medium-4 col-small-3">
+      <p>{children}</p>
+    </div>
+  </div>
+);
+
+TableCaption.Title = Title;
+TableCaption.Description = Description;
+
+export default TableCaption;
diff --git a/frontend/src/components/TableCaption/index.ts b/frontend/src/components/TableCaption/index.ts
new file mode 100644
index 0000000..05549b0
--- /dev/null
+++ b/frontend/src/components/TableCaption/index.ts
@@ -0,0 +1 @@
+export { default } from "./TableCaption";
diff --git a/frontend/src/hooks/api.ts b/frontend/src/hooks/api.ts
index 921b83e..0448ea5 100644
--- a/frontend/src/hooks/api.ts
+++ b/frontend/src/hooks/api.ts
@@ -1,8 +1,8 @@
 import { useMutation, useQuery } from "@tanstack/react-query";
 
-import type { GetSitesQueryParams, GetTokensQueryParams } from "@/api/handlers";
-import { postTokens, getSites, getTokens } from "@/api/handlers";
-import type { SitesQueryResult, PostTokensResult } from "@/api/types";
+import type { GetEnrollmentRequestsQueryParams, GetSitesQueryParams, GetTokensQueryParams } from "@/api/handlers";
+import { getEnrollmentRequests, postTokens, getSites, getTokens } from "@/api/handlers";
+import type { SitesQueryResult, PostTokensResult, EnrollmentRequestsQueryResult } from "@/api/types";
 
 export type UseSitesQueryResult = ReturnType<typeof useSitesQuery>;
 
@@ -25,3 +25,12 @@ export const useTokensQuery = ({ page, size }: GetTokensQueryParams) =>
   });
 
 export const useTokensMutation = () => useMutation(postTokens);
+
+export type UseEnrollmentRequestsQueryResult = ReturnType<typeof useRequestsQuery>;
+export const useRequestsQuery = ({ page, size }: GetEnrollmentRequestsQueryParams) =>
+  useQuery<EnrollmentRequestsQueryResult>({
+    queryKey: ["requests", page, size],
+    queryFn: () => getEnrollmentRequests({ page, size }),
+    keepPreviousData: true,
+    refetchInterval: defaultRefetchInterval,
+  });
diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts
index 2ce8731..200b1ae 100644
--- a/frontend/src/mocks/browser.ts
+++ b/frontend/src/mocks/browser.ts
@@ -1,5 +1,5 @@
 import { setupWorker } from "msw";
 
-import { getSites, getTokens, postTokens } from "./resolvers";
+import { getSites, getTokens, getEnrollmentRequests, postTokens } from "./resolvers";
 
-export const worker = setupWorker(getSites, postTokens, getTokens);
+export const worker = setupWorker(getSites, postTokens, getEnrollmentRequests, getTokens);
diff --git a/frontend/src/mocks/factories.ts b/frontend/src/mocks/factories.ts
index d1dccba..3daf037 100644
--- a/frontend/src/mocks/factories.ts
+++ b/frontend/src/mocks/factories.ts
@@ -2,7 +2,7 @@ import Chance from "chance";
 import { Factory } from "fishery";
 import { uniqueNamesGenerator, adjectives, colors, animals } from "unique-names-generator";
 
-import type { Site, Token } from "@/api/types";
+import type { EnrollmentRequest, PaginatedQueryResult, Site, Token } from "@/api/types";
 
 export const connections: Site["connection"][] = ["stable", "lost", "unknown"];
 
@@ -40,6 +40,14 @@ export const siteFactory = Factory.define<Site>(({ sequence }) => {
   };
 });
 
+export const paginatedQueryResultFactory = <T extends unknown>() =>
+  Factory.define<PaginatedQueryResult<T>>(() => {
+    return { items: [], total: 0, page: 0, size: 0 };
+  });
+
+export const enrollmentRequestQueryResultFactory = paginatedQueryResultFactory<EnrollmentRequest>();
+export const sitesQueryResultFactory = paginatedQueryResultFactory<Site>();
+
 export const tokenFactory = Factory.define<Token>(({ sequence }) => {
   const chance = new Chance(`maas-${sequence}`);
   return {
@@ -49,3 +57,19 @@ export const tokenFactory = Factory.define<Token>(({ sequence }) => {
     created: new Date(chance.date({ year: 2023 })).toISOString(), //<ISO 8601 date string>
   };
 });
+
+export const enrollmentRequestFactory = Factory.define<EnrollmentRequest>(({ sequence }) => {
+  const chance = new Chance(`maas-${sequence}`);
+  const name = uniqueNamesGenerator({
+    dictionaries: [adjectives, colors, animals],
+    separator: "-",
+    length: 2,
+    seed: sequence,
+  });
+  return {
+    id: `request-${sequence}`,
+    name,
+    url: `http://${name}.${chance.tld()}`,
+    created: new Date(chance.date({ year: 2023 })).toISOString(), //<ISO 8601 date string>
+  };
+});
diff --git a/frontend/src/mocks/resolvers.ts b/frontend/src/mocks/resolvers.ts
index 9c2d313..2a0b3fb 100644
--- a/frontend/src/mocks/resolvers.ts
+++ b/frontend/src/mocks/resolvers.ts
@@ -1,13 +1,17 @@
 import { rest } from "msw";
 import type { RestRequest, restContext, ResponseResolver } from "msw";
 
-import { siteFactory, tokenFactory } from "./factories";
+import { siteFactory, tokenFactory, enrollmentRequestFactory } from "./factories";
 
 import urls from "@/api/urls";
 import type { GetSitesQueryParams, PostTokensData } from "api/handlers";
 
 export const sitesList = siteFactory.buildList(155);
 export const tokensList = tokenFactory.buildList(100);
+export const enrollmentRequestsList = [
+  enrollmentRequestFactory.build({ created: undefined }),
+  ...enrollmentRequestFactory.buildList(100),
+];
 
 type SitesResponseResolver = ResponseResolver<RestRequest<never, GetSitesQueryParams>, typeof restContext>;
 export const createMockSitesResolver =
@@ -59,6 +63,24 @@ export const createMockGetTokensResolver =
     return res(ctx.json(response));
   };
 
+export const createMockGetEnrollmentRequestsResolver =
+  (enrollmentRequests = enrollmentRequestsList): TokensResponseResolver =>
+  (req, res, ctx) => {
+    const searchParams = new URLSearchParams(req.url.search);
+    const page = Number(searchParams.get("page"));
+    const size = Number(searchParams.get("size"));
+    const itemsPage = enrollmentRequests.slice(page * Number(size), (page + 1) * size);
+
+    const response = {
+      items: itemsPage,
+      page,
+      total: enrollmentRequests.length,
+    };
+
+    return res(ctx.json(response));
+  };
+
 export const getSites = rest.get(urls.sites, createMockSitesResolver());
 export const postTokens = rest.post(urls.tokens, createMockTokensResolver());
 export const getTokens = rest.get(urls.tokens, createMockGetTokensResolver());
+export const getEnrollmentRequests = rest.get(urls.enrollmentRequests, createMockGetEnrollmentRequestsResolver());
diff --git a/frontend/src/pages/requests.tsx b/frontend/src/pages/requests.tsx
index f1bd46d..1b54444 100644
--- a/frontend/src/pages/requests.tsx
+++ b/frontend/src/pages/requests.tsx
@@ -1,7 +1,30 @@
-const Requests: React.FC = () => (
-  <section>
-    <h2>Requests</h2>
-  </section>
-);
+import { useState } from "react";
+
+import { Col, Row } from "@canonical/react-components";
+
+import RequestsTable from "@/components/RequestsTable";
+import { useRequestsQuery } from "@/hooks/api";
+
+const Requests: React.FC = () => {
+  // TODO: update page and size when pagination is implemented
+  // https://warthogs.atlassian.net/browse/MAASENG-1525
+  const [page] = useState<number>(0);
+  const [size] = useState<number>(50);
+  const { data, isLoading, isFetchedAfterMount } = useRequestsQuery({ page: `${page}`, size: `${size}` });
+  return (
+    <section>
+      <Row>
+        <Col size={2}>
+          <h2 className="p-heading--4">Requests</h2>
+        </Col>
+      </Row>
+      <Row>
+        <Col size={12}>
+          <RequestsTable data={data} isFetchedAfterMount={isFetchedAfterMount} isLoading={isLoading} />
+        </Col>
+      </Row>
+    </section>
+  );
+};
 
 export default Requests;
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index 2cd8876..b9ebdc8 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -1,6 +1,7 @@
 import { createRoutesFromElements, Route, redirect } from "react-router-dom";
 
 import MainLayout from "@/components/MainLayout";
+import Requests from "@/pages/requests";
 import SitesList from "@/pages/sites";
 import Tokens from "@/pages/tokens/tokens";
 
@@ -14,7 +15,7 @@ export const routes = createRoutesFromElements(
     <Route path="login" />
     <Route path="logout" />
     <Route element={<SitesList />} path="sites" />
-    <Route path="requests" />
+    <Route element={<Requests />} path="requests" />
     <Route element={<Tokens />} path="tokens" />
     <Route path="users" />
   </Route>,
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
index e199c7f..dcf7a2d 100644
--- a/frontend/src/utils.ts
+++ b/frontend/src/utils.ts
@@ -1,3 +1,4 @@
+import { format } from "date-fns";
 import { getTimezoneOffset } from "date-fns-tz";
 import * as countries from "i18n-iso-countries";
 import { getName } from "i18n-iso-countries";
@@ -49,3 +50,5 @@ export const getTimeByUTCOffset = (date: Date, offset: number) => {
   const minutes = `${new Date(updatedTime).getUTCMinutes()}`.padStart(2, "0");
   return hours + ":" + minutes;
 };
+
+export const formatUTCDateString = (dateString: string) => format(new Date(dateString), "yyyy-MM-dd HH:MM");
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index fd9102f..499d977 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -5349,6 +5349,13 @@ react-dom@18.2.0:
     loose-envify "^1.1.0"
     scheduler "^0.23.0"
 
+react-error-boundary@4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-4.0.3.tgz#f811497c06d53ea1206817ee82c6e5c6a27becd9";
+  integrity sha512-IzNKP/ViHWp2QRDgsDMirEcf0XLsLueN6Wgzm1TVwgbAH+paX8Z42VyKvZcFFRHgd+rPK2P4TLrOrHC/dommew==
+  dependencies:
+    "@babel/runtime" "^7.12.5"
+
 react-error-boundary@^3.1.0:
   version "3.1.4"
   resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0";
-- 
Mailing list: https://launchpad.net/~sts-sponsors
Post to     : sts-sponsors@lists.launchpad.net
Unsubscribe : https://launchpad.net/~sts-sponsors
More help   : https://help.launchpad.net/ListHelp

Reply via email to