sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #06582
[Merge] ~petermakowski/maas-site-manager:MAASENG-1509-add-requests-table into maas-site-manager:main
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"
References