← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~petermakowski/maas-site-manager:display-request-errors-MAASENG-1629 into maas-site-manager:main

 

Peter Makowski has proposed merging ~petermakowski/maas-site-manager:display-request-errors-MAASENG-1629 into maas-site-manager:main.

Commit message:
display request errors
- add vanilla animation utilities
- update TableCaption width on mobile
- rely on automatic Sentry error capturing instead of manual captureException
- remove redundant isFetchedAfterMount

Requested reviews:
  MAAS Committers (maas-committers)

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

# QA Steps
Edit `resolvers.ts` file by replacing lines `46`, `62`,`72` with `return res(ctx.status(400));`
Go to /sites
Verify that the error message (after a number of retries) has been displayed
Go to /settings/tokens
Verify that the error message (after a number of retries) has been displayed
Go to /settings/requests
Verify that the error message (after a number of retries) has been displayed

# Screenshots
https://share.cleanshot.com/SVdcb51c
-- 
Your team MAAS Committers is requested to review the proposed merge of ~petermakowski/maas-site-manager:display-request-errors-MAASENG-1629 into maas-site-manager:main.
diff --git a/frontend/src/App.scss b/frontend/src/App.scss
index b904532..cad8a9a 100644
--- a/frontend/src/App.scss
+++ b/frontend/src/App.scss
@@ -45,6 +45,7 @@
 @include vf-u-align;
 @include vf-u-margin-collapse;
 @include vf-u-show;
+@include vf-u-animations;
 
 // layout
 @include vf-l-application;
diff --git a/frontend/src/api/handlers.ts b/frontend/src/api/handlers.ts
index 70d4775..3b5c995 100644
--- a/frontend/src/api/handlers.ts
+++ b/frontend/src/api/handlers.ts
@@ -1,5 +1,3 @@
-import * as Sentry from "@sentry/browser";
-
 import api from "./api";
 import type { Token } from "./types";
 import urls from "./urls";
@@ -44,7 +42,7 @@ export const getSites = async (params: GetSitesQueryParams, queryText?: string) 
     });
     return response.data;
   } catch (error) {
-    Sentry.captureException(new Error("getSites failed", { cause: error }));
+    throw error;
   }
 };
 
@@ -71,7 +69,7 @@ export const getTokens = async (params: GetTokensQueryParams) => {
     const response = await api.get(urls.tokens, { params });
     return response.data;
   } catch (error) {
-    Sentry.captureException(new Error("getTokens failed", { cause: error }));
+    throw error;
   }
 };
 
@@ -92,7 +90,7 @@ export const getEnrollmentRequests = async (params: GetEnrollmentRequestsQueryPa
     const response = await api.get(urls.enrollmentRequests, { params });
     return response.data;
   } catch (error) {
-    Sentry.captureException(new Error("getEnrollmentRequests failed", { cause: error }));
+    throw error;
   }
 };
 
diff --git a/frontend/src/components/ErrorMessage/ErrorMessage.test.tsx b/frontend/src/components/ErrorMessage/ErrorMessage.test.tsx
new file mode 100644
index 0000000..6900e42
--- /dev/null
+++ b/frontend/src/components/ErrorMessage/ErrorMessage.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from "@testing-library/react";
+
+import ErrorMessage from "./ErrorMessage";
+
+it("renders the error message if error is an instance of Error", () => {
+  const testError = new Error("Test error message");
+  render(<ErrorMessage error={testError} />);
+  expect(screen.getByText("Test error message")).toBeInTheDocument();
+});
+
+it("renders provided error string", () => {
+  const error = "This is a string error";
+  render(<ErrorMessage error={error} />);
+  expect(screen.getByText("This is a string error")).toBeInTheDocument();
+});
+
+it("renders a default error message if no error is provided", () => {
+  render(<ErrorMessage error={undefined} />);
+  expect(screen.getByText("An unknown error has occured")).toBeInTheDocument();
+});
diff --git a/frontend/src/components/ErrorMessage/ErrorMessage.tsx b/frontend/src/components/ErrorMessage/ErrorMessage.tsx
new file mode 100644
index 0000000..6f99272
--- /dev/null
+++ b/frontend/src/components/ErrorMessage/ErrorMessage.tsx
@@ -0,0 +1,5 @@
+const ErrorMessage = ({ error }: { error: unknown }) => (
+  <>{error instanceof Error ? error.message : typeof error === "string" ? error : "An unknown error has occured"}</>
+);
+
+export default ErrorMessage;
diff --git a/frontend/src/components/ErrorMessage/index.ts b/frontend/src/components/ErrorMessage/index.ts
new file mode 100644
index 0000000..3b2ac0c
--- /dev/null
+++ b/frontend/src/components/ErrorMessage/index.ts
@@ -0,0 +1 @@
+export { default } from "./ErrorMessage";
diff --git a/frontend/src/components/RequestsList/RequestsList.tsx b/frontend/src/components/RequestsList/RequestsList.tsx
index c3a3e8c..c86f595 100644
--- a/frontend/src/components/RequestsList/RequestsList.tsx
+++ b/frontend/src/components/RequestsList/RequestsList.tsx
@@ -13,7 +13,7 @@ const Requests: React.FC = () => {
   const [totalDataCount, setTotalDataCount] = useState(0);
   const { page, debouncedPage, size, handleNextClick, handlePreviousClick, handlePageSizeChange, setPage } =
     usePagination(DEFAULT_PAGE_SIZE, totalDataCount);
-  const { data, isLoading, isFetchedAfterMount } = useRequestsQuery({
+  const { error, data, isLoading } = useRequestsQuery({
     page: `${debouncedPage}`,
     size: `${size}`,
   });
@@ -42,7 +42,7 @@ const Requests: React.FC = () => {
           />
         </Col>
         <Col size={12}>
-          <RequestsTable data={data} isFetchedAfterMount={isFetchedAfterMount} isLoading={isLoading} />
+          <RequestsTable data={data} error={error} isLoading={isLoading} />
         </Col>
       </Row>
     </section>
diff --git a/frontend/src/components/RequestsTable/RequestsTable.test.tsx b/frontend/src/components/RequestsTable/RequestsTable.test.tsx
index 1d2ff3e..90f4700 100644
--- a/frontend/src/components/RequestsTable/RequestsTable.test.tsx
+++ b/frontend/src/components/RequestsTable/RequestsTable.test.tsx
@@ -6,23 +6,21 @@ import { formatUTCDateString } from "@/utils";
 
 it("displays a loading text", () => {
   const { rerender } = renderWithMemoryRouter(
-    <RequestsTable data={enrollmentRequestQueryResultFactory.build()} isFetchedAfterMount={false} isLoading={true} />,
+    <RequestsTable data={enrollmentRequestQueryResultFactory.build()} error={undefined} 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} />,
-  );
+  rerender(<RequestsTable data={enrollmentRequestQueryResultFactory.build()} error={undefined} 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} />,
+    <RequestsTable data={enrollmentRequestQueryResultFactory.build()} error={undefined} isLoading={false} />,
   );
 
   const table = screen.getByRole("table", { name: /enrollment requests/i });
@@ -33,11 +31,7 @@ it("should show a message if there are no open enrolment requests", () => {
 it("displays enrollment request in each table row correctly", () => {
   const items = enrollmentRequestFactory.buildList(1);
   renderWithMemoryRouter(
-    <RequestsTable
-      data={enrollmentRequestQueryResultFactory.build({ items })}
-      isFetchedAfterMount={true}
-      isLoading={false}
-    />,
+    <RequestsTable data={enrollmentRequestQueryResultFactory.build({ items })} error={undefined} isLoading={false} />,
   );
 
   const tableBody = screen.getAllByRole("rowgroup")[1];
diff --git a/frontend/src/components/RequestsTable/RequestsTable.tsx b/frontend/src/components/RequestsTable/RequestsTable.tsx
index 20887c4..34e109f 100644
--- a/frontend/src/components/RequestsTable/RequestsTable.tsx
+++ b/frontend/src/components/RequestsTable/RequestsTable.tsx
@@ -20,9 +20,9 @@ const columnHelper = createColumnHelper<EnrollmentRequest>();
 
 const RequestsTable = ({
   data,
-  isFetchedAfterMount,
+  error,
   isLoading,
-}: Pick<UseEnrollmentRequestsQueryResult, "data" | "isLoading" | "isFetchedAfterMount">) => {
+}: Pick<UseEnrollmentRequestsQueryResult, "data" | "error" | "isLoading">) => {
   const { rowSelection, setRowSelection } = useAppContext();
 
   // clear selection on unmount
@@ -106,8 +106,14 @@ const RequestsTable = ({
             </tr>
           ))}
         </thead>
-        {isLoading && !isFetchedAfterMount ? (
-          <caption>Loading...</caption>
+        {error ? (
+          <TableCaption>
+            <TableCaption.Error error={error} />
+          </TableCaption>
+        ) : isLoading ? (
+          <TableCaption>
+            <TableCaption.Loading />
+          </TableCaption>
         ) : table.getRowModel().rows.length < 1 ? (
           <TableCaption>
             <TableCaption.Title>No outstanding requests</TableCaption.Title>
diff --git a/frontend/src/components/SitesList/SitesList.tsx b/frontend/src/components/SitesList/SitesList.tsx
index 937bf1d..dde06b2 100644
--- a/frontend/src/components/SitesList/SitesList.tsx
+++ b/frontend/src/components/SitesList/SitesList.tsx
@@ -16,7 +16,7 @@ const SitesList = () => {
   const [searchText, setSearchText] = useState("");
   const debounceSearchText = useDebounce(searchText);
 
-  const { data, isLoading, isFetchedAfterMount } = useSitesQuery(
+  const { error, data, isLoading } = useSitesQuery(
     { page: `${debouncedPage}`, size: `${size}` },
     parseSearchTextToQueryParams(debounceSearchText),
   );
@@ -35,7 +35,7 @@ const SitesList = () => {
     <div>
       <SitesTable
         data={data}
-        isFetchedAfterMount={isFetchedAfterMount}
+        error={error}
         isLoading={isLoading}
         paginationProps={{
           currentPage: page,
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
index 068bac2..40a5c6b 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.test.tsx
@@ -22,6 +22,14 @@ const paginationProps = {
   setCurrentPage: vi.fn,
   totalItems: 1,
 };
+const commonProps = {
+  error: undefined,
+  isLoading: false,
+  paginationProps: {
+    ...paginationProps,
+  },
+  setSearchText: vi.fn(),
+};
 
 beforeEach(() => {
   vi.useFakeTimers();
@@ -40,14 +48,12 @@ afterAll(() => {
 it("displays an empty sites table", () => {
   renderWithMemoryRouter(
     <SitesTable
+      {...commonProps}
       data={sitesQueryResultFactory.build()}
-      isFetchedAfterMount={true}
-      isLoading={false}
       paginationProps={{
         ...paginationProps,
         totalItems: 0,
       }}
-      setSearchText={vi.fn()}
     />,
   );
 
@@ -58,13 +64,11 @@ it("displays rows with details for each site", () => {
   const items = siteFactory.buildList(1);
   renderWithMemoryRouter(
     <SitesTable
+      {...commonProps}
       data={sitesQueryResultFactory.build({ items, total: 1, page: 1, size: 1 })}
-      isFetchedAfterMount={true}
-      isLoading={false}
       paginationProps={{
         ...paginationProps,
       }}
-      setSearchText={vi.fn()}
     />,
   );
 
@@ -83,7 +87,7 @@ it("displays correctly paginated results", () => {
   renderWithMemoryRouter(
     <SitesTable
       data={sitesQueryResultFactory.build({ items, total: 100, page: 1, size: pageLength })}
-      isFetchedAfterMount={true}
+      error={undefined}
       isLoading={false}
       paginationProps={{
         ...paginationProps,
@@ -106,7 +110,7 @@ it("displays correct local time", () => {
   renderWithMemoryRouter(
     <SitesTable
       data={sitesQueryResultFactory.build({ items: [item], total: 1, page: 1, size: 1 })}
-      isFetchedAfterMount={true}
+      error={undefined}
       isLoading={false}
       paginationProps={{
         ...paginationProps,
@@ -124,7 +128,7 @@ it("displays full name of the country", () => {
   renderWithMemoryRouter(
     <SitesTable
       data={sitesQueryResultFactory.build({ items: [item], total: 1, page: 1, size: 1 })}
-      isFetchedAfterMount={true}
+      error={undefined}
       isLoading={false}
       paginationProps={{
         ...paginationProps,
@@ -147,13 +151,11 @@ it("displays correct number of deployed machines", () => {
   });
   renderWithMemoryRouter(
     <SitesTable
+      {...commonProps}
       data={sitesQueryResultFactory.build({ items: [item], total: 1, page: 1, size: 1 })}
-      isFetchedAfterMount={true}
-      isLoading={false}
       paginationProps={{
         ...paginationProps,
       }}
-      setSearchText={vi.fn()}
     />,
   );
 
@@ -166,13 +168,11 @@ it("if name is not unique a warning is displayed.", async () => {
   });
   const { rerender } = renderWithMemoryRouter(
     <SitesTable
+      {...commonProps}
       data={sitesQueryResultFactory.build({ items: [itemUnique], total: 1, page: 1, size: 1 })}
-      isFetchedAfterMount={true}
-      isLoading={false}
       paginationProps={{
         ...paginationProps,
       }}
-      setSearchText={vi.fn()}
     />,
   );
 
@@ -183,13 +183,11 @@ it("if name is not unique a warning is displayed.", async () => {
   });
   rerender(
     <SitesTable
+      {...commonProps}
       data={sitesQueryResultFactory.build({ items: [itemNonUnique], total: 1, page: 1, size: 1 })}
-      isFetchedAfterMount={true}
-      isLoading={false}
       paginationProps={{
         ...paginationProps,
       }}
-      setSearchText={vi.fn()}
     />,
   );
 
@@ -200,15 +198,13 @@ it("displays a pagination bar with the table", () => {
   const items = siteFactory.buildList(2);
   renderWithMemoryRouter(
     <SitesTable
+      {...commonProps}
       data={sitesQueryResultFactory.build({ items, total: 2, page: 1, size: 10 })}
-      isFetchedAfterMount={true}
-      isLoading={false}
       paginationProps={{
         ...paginationProps,
         itemsPerPage: 10,
         totalItems: 2,
       }}
-      setSearchText={vi.fn()}
     />,
   );
 
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
index 32439cd..f368905 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTable.tsx
@@ -14,6 +14,7 @@ import type { SitesQueryResult } from "@/api/types";
 import ExternalLink from "@/components/ExternalLink";
 import NoRegions from "@/components/NoRegions";
 import SelectAllCheckbox from "@/components/SelectAllCheckbox";
+import TableCaption from "@/components/TableCaption/TableCaption";
 import type { PaginationBarProps } from "@/components/base/PaginationBar/PaginationBar";
 import PaginationBar from "@/components/base/PaginationBar/PaginationBar";
 import TooltipButton from "@/components/base/TooltipButton/TooltipButton";
@@ -33,11 +34,11 @@ export type SitesColumn = Column<Site, unknown>;
 
 const SitesTable = ({
   data,
-  isFetchedAfterMount,
   isLoading,
+  error,
   setSearchText,
   paginationProps,
-}: Pick<UseSitesQueryResult, "data" | "isLoading" | "isFetchedAfterMount"> & {
+}: Pick<UseSitesQueryResult, "data" | "isLoading" | "error"> & {
   setSearchText: (text: string) => void;
   paginationProps: PaginationBarProps;
 }) => {
@@ -229,17 +230,7 @@ const SitesTable = ({
         isLoading={isLoading}
         setSearchText={setSearchText}
       />
-      <PaginationBar
-        currentPage={paginationProps.currentPage}
-        dataContext={paginationProps.dataContext}
-        handlePageSizeChange={paginationProps.handlePageSizeChange}
-        isLoading={paginationProps.isLoading}
-        itemsPerPage={paginationProps.itemsPerPage}
-        onNextClick={paginationProps.onNextClick}
-        onPreviousClick={paginationProps.onPreviousClick}
-        setCurrentPage={paginationProps.setCurrentPage}
-        totalItems={paginationProps.totalItems}
-      />
+      <PaginationBar {...paginationProps} />
       <table aria-label="sites" className="sites-table">
         <thead>
           {table.getHeaderGroups().map((headerGroup) => (
@@ -254,8 +245,14 @@ const SitesTable = ({
             </tr>
           ))}
         </thead>
-        {isLoading && !isFetchedAfterMount ? (
-          <caption>Loading...</caption>
+        {error ? (
+          <TableCaption>
+            <TableCaption.Error error={error} />
+          </TableCaption>
+        ) : isLoading ? (
+          <TableCaption>
+            <TableCaption.Loading />
+          </TableCaption>
         ) : table.getRowModel().rows.length < 1 ? (
           <NoRegions />
         ) : (
diff --git a/frontend/src/components/TableCaption/TableCaption.test.tsx b/frontend/src/components/TableCaption/TableCaption.test.tsx
new file mode 100644
index 0000000..79bd80e
--- /dev/null
+++ b/frontend/src/components/TableCaption/TableCaption.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+
+import TableCaption from "./TableCaption";
+
+test("TableCaption displays children as caption", () => {
+  render(
+    <table>
+      <TableCaption>caption text</TableCaption>
+    </table>,
+  );
+  expect(screen.getByText("caption text")).toBeInTheDocument();
+  expect(screen.getByRole("table", { name: "caption text" })).toBeInTheDocument();
+});
+
+test("TableCaption.Title displays title", () => {
+  render(<TableCaption.Title>Test Title</TableCaption.Title>);
+  expect(screen.getByText("Test Title")).toBeInTheDocument();
+});
+
+test("TableCaption.Title displays title", () => {
+  render(<TableCaption.Description>Test description</TableCaption.Description>);
+  expect(screen.getByText("Test description")).toBeInTheDocument();
+});
+
+test("TableCaption.Error renders error message", () => {
+  const error = new Error("Test error message");
+  render(<TableCaption.Error error={error} />);
+  expect(screen.getByText("Test error message")).toBeInTheDocument();
+});
+
+test("TableCaption.Loading renders spinner with text", () => {
+  render(<TableCaption.Loading />);
+  expect(screen.getByText("Loading...")).toBeInTheDocument();
+});
diff --git a/frontend/src/components/TableCaption/TableCaption.tsx b/frontend/src/components/TableCaption/TableCaption.tsx
index e9d1c44..49a297c 100644
--- a/frontend/src/components/TableCaption/TableCaption.tsx
+++ b/frontend/src/components/TableCaption/TableCaption.tsx
@@ -1,5 +1,9 @@
 import type { PropsWithChildren } from "react";
 
+import { Notification, Spinner } from "@canonical/react-components";
+
+import ErrorMessage from "@/components/ErrorMessage";
+
 type TableCationProps = PropsWithChildren;
 
 const TableCaption = ({ children }: TableCationProps) => (
@@ -10,7 +14,7 @@ const TableCaption = ({ children }: TableCationProps) => (
 
 const Title = ({ children }: TableCationProps) => (
   <div className="row">
-    <div className="col-start-large-4 u-align--left col-8 col-medium-4 col-small-3">
+    <div className="col-start-large-4 u-align--left col-8 col-medium-4">
       <p className="p-heading--4 u-no-margin--bottom">{children}</p>
     </div>
   </div>
@@ -18,13 +22,25 @@ const Title = ({ children }: TableCationProps) => (
 
 const Description = ({ children }: TableCationProps) => (
   <div className="row">
-    <div className="u-align--left col-start-large-4 col-8 col-medium-4 col-small-3">
+    <div className="u-align--left col-start-large-4 col-8 col-medium-4">
       <p>{children}</p>
     </div>
   </div>
 );
 
+const Error = ({ error }: React.ComponentProps<typeof ErrorMessage>) => (
+  <div className="row">
+    <div className="u-align--left col-start-large-4 col-8 col-medium-4">
+      <Notification severity="negative" title={<ErrorMessage error={error} />} />
+    </div>
+  </div>
+);
+
+const Loading = () => <Spinner text="Loading..." />;
+
 TableCaption.Title = Title;
 TableCaption.Description = Description;
+TableCaption.Loading = Loading;
+TableCaption.Error = Error;
 
 export default TableCaption;
diff --git a/frontend/src/components/TableError/index.ts b/frontend/src/components/TableError/index.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/frontend/src/components/TableError/index.ts
diff --git a/frontend/src/components/TokensList/TokensList.tsx b/frontend/src/components/TokensList/TokensList.tsx
index b805a11..12d0f09 100644
--- a/frontend/src/components/TokensList/TokensList.tsx
+++ b/frontend/src/components/TokensList/TokensList.tsx
@@ -21,7 +21,7 @@ const TokensList = () => {
   const { page, debouncedPage, size, handleNextClick, handlePreviousClick, handlePageSizeChange, setPage } =
     usePagination(DEFAULT_PAGE_SIZE, totalDataCount);
 
-  const { data, isLoading, isFetchedAfterMount, isSuccess } = useTokensQuery({
+  const { error, data, isLoading, isSuccess } = useTokensQuery({
     page: `${debouncedPage}`,
     size: `${size}`,
   });
@@ -109,7 +109,7 @@ const TokensList = () => {
           totalItems={data?.total || 0}
         />
       </header>
-      <TokensTable data={data} isFetchedAfterMount={isFetchedAfterMount} isLoading={isLoading} />
+      <TokensTable data={data} error={error} isLoading={isLoading} />
     </section>
   );
 };
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx
index 289267c..6f983e8 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.test.tsx
@@ -13,14 +13,14 @@ afterEach(() => {
 });
 
 it("displays the tokens table", () => {
-  render(<TokensTable data={{ items: [], total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+  render(<TokensTable data={{ items: [], total: 0, page: 1, size: 0 }} error={undefined} isLoading={false} />);
 
   expect(screen.getByRole("table", { name: /tokens/i })).toBeInTheDocument();
 });
 
 it("displays rows for each token", () => {
   const items = tokenFactory.buildList(1);
-  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} error={undefined} isLoading={false} />);
 
   const tableBody = screen.getAllByRole("rowgroup")[1];
   expect(within(tableBody).getAllByRole("row")).toHaveLength(items.length);
@@ -31,7 +31,7 @@ it("displays rows for each token", () => {
 
 it("displays a copy button in each row", () => {
   const items = tokenFactory.buildList(1);
-  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} error={undefined} isLoading={false} />);
 
   const tableBody = screen.getAllByRole("rowgroup")[1];
   within(tableBody)
@@ -43,7 +43,7 @@ it("displays a copy button in each row", () => {
 
 it("should display a no-tokens caption if there are no tokens", () => {
   const items: Token[] = [];
-  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} error={undefined} isLoading={false} />);
 
   expect(screen.getByText(/No tokens available/i)).toBeInTheDocument();
 });
@@ -53,7 +53,7 @@ it("displays created date in UTC", () => {
   vi.setSystemTime(date);
   const items = [tokenFactory.build({ created: "2023-04-21T11:30:00.000Z" })];
 
-  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} error={undefined} isLoading={false} />);
 
   expect(screen.getByText(/2023-04-21 11:30/i)).toBeInTheDocument();
 });
@@ -63,7 +63,7 @@ it("displays time until expiration in UTC", () => {
   vi.setSystemTime(date);
   const items = [tokenFactory.build({ expired: "2023-04-21T14:00:00.000Z" })];
 
-  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} isFetchedAfterMount={true} isLoading={false} />);
+  render(<TokensTable data={{ items, total: 0, page: 1, size: 0 }} error={undefined} isLoading={false} />);
 
   expect(screen.getByText(/in 2 hours/i)).toBeInTheDocument();
 });
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
index 3c4d33a..42cbf90 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
@@ -6,6 +6,7 @@ import pick from "lodash/fp/pick";
 
 import type { Token } from "@/api/types";
 import SelectAllCheckbox from "@/components/SelectAllCheckbox";
+import TableCaption from "@/components/TableCaption";
 import CopyButton from "@/components/base/CopyButton";
 import TooltipButton from "@/components/base/TooltipButton";
 import { useAppContext } from "@/context";
@@ -20,11 +21,7 @@ const createAccessor =
 export type TokenColumnDef = ColumnDef<Token, Partial<Token>>;
 export type TokenColumn = Column<Token, unknown>;
 
-const TokensTable = ({
-  data,
-  isFetchedAfterMount,
-  isLoading,
-}: Pick<useTokensQueryResult, "data" | "isLoading" | "isFetchedAfterMount">) => {
+const TokensTable = ({ data, error, isLoading }: Pick<useTokensQueryResult, "data" | "error" | "isLoading">) => {
   const [copiedText, setCopiedText] = useState("");
   const { rowSelection, setRowSelection } = useAppContext();
   const isTokenCopied = useCallback((token: string) => token === copiedText, [copiedText]);
@@ -135,8 +132,21 @@ const TokensTable = ({
           </tr>
         ))}
       </thead>
-      {isLoading && !isFetchedAfterMount ? (
-        <caption>Loading...</caption>
+      {error ? (
+        <TableCaption>
+          <TableCaption.Error error={error} />
+        </TableCaption>
+      ) : isLoading ? (
+        <TableCaption>
+          <TableCaption.Loading />
+        </TableCaption>
+      ) : tokenTable.getRowModel().rows.length < 1 ? (
+        <TableCaption>
+          <TableCaption.Title>No tokens available</TableCaption.Title>
+          <TableCaption.Description>
+            Generate new tokens and follow the instructions above to enrol MAAS regions.
+          </TableCaption.Description>
+        </TableCaption>
       ) : (
         <tbody>
           {tokenTable.getRowModel().rows.map((row) => (
@@ -148,14 +158,6 @@ const TokensTable = ({
           ))}
         </tbody>
       )}
-      {!isLoading && data?.items.length === 0 ? (
-        <caption className="empty-table-caption">
-          <div className="empty-table-caption__body">
-            <h2>No tokens available</h2>
-            <p className="p-heading--4">Generate new tokens and follow the instructions above to enrol MAAS regions.</p>
-          </div>
-        </caption>
-      ) : null}
     </table>
   );
 };

Follow ups