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