← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~jonesogolo/maas-site-manager:1547-enrollment-tokens-delete-action into maas-site-manager:main

 

Jones Ogolo has proposed merging ~jonesogolo/maas-site-manager:1547-enrollment-tokens-delete-action into maas-site-manager:main.

Commit message:
Created token deletion request
Add notification after token deletion
Enable delete button on row selection

Requested reviews:
  MAAS Committers (maas-committers)

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

Steps to QA:
- Goto "/settings/tokens" 
- ensure delete button is disabled
- select a table row by clicking the checkbox
- Ensure that you see a delete notification
Note: Because mock data is being used at the momemt, the data is not actually deleted.
-- 
Your team MAAS Committers is requested to review the proposed merge of ~jonesogolo/maas-site-manager:1547-enrollment-tokens-delete-action into maas-site-manager:main.
diff --git a/frontend/src/api/handlers.ts b/frontend/src/api/handlers.ts
index af8f4a5..46be37c 100644
--- a/frontend/src/api/handlers.ts
+++ b/frontend/src/api/handlers.ts
@@ -78,6 +78,17 @@ export const getTokens = async (params: GetTokensQueryParams) => {
   }
 };
 
+export const deleteTokens = async (data: string[]) => {
+  if (data.length === 0) {
+    throw Error("No tokens selected");
+  }
+  try {
+    await api.delete(urls.tokens, { data });
+  } catch (error) {
+    throw error;
+  }
+};
+
 export type GetEnrollmentRequestsQueryParams = PaginationParams & {};
 export const getEnrollmentRequests = async (params: GetEnrollmentRequestsQueryParams) => {
   try {
diff --git a/frontend/src/components/TokensList/TokensList.test.tsx b/frontend/src/components/TokensList/TokensList.test.tsx
index 631096a..4c40c42 100644
--- a/frontend/src/components/TokensList/TokensList.test.tsx
+++ b/frontend/src/components/TokensList/TokensList.test.tsx
@@ -1,15 +1,19 @@
 import { waitFor } from "@testing-library/react";
+import { rest } from "msw";
+import { setupServer } from "msw/node";
 
 import TokensList from "./TokensList";
 
 import urls from "@/api/urls";
 import { tokenFactory } from "@/mocks/factories";
-import { createMockGetTokensResolver } from "@/mocks/resolvers";
-import { createMockGetServer } from "@/mocks/server";
+import { createMockDeleteTokensResolver, createMockGetTokensResolver } from "@/mocks/resolvers";
 import { screen, renderWithMemoryRouter, within, userEvent } from "@/test-utils";
 
 const tokens = tokenFactory.buildList(2);
-const mockServer = createMockGetServer(urls.tokens, createMockGetTokensResolver(tokens));
+const mockServer = setupServer(
+  rest.get(urls.tokens, createMockGetTokensResolver(tokens)),
+  rest.delete(urls.tokens, createMockDeleteTokensResolver()),
+);
 
 beforeAll(() => {
   mockServer.listen();
@@ -42,7 +46,7 @@ it("should display table with tokens", async () => {
     .forEach((row, idx) => expect(row).toHaveTextContent(new RegExp(tokens[idx].token, "i")));
 });
 
-it("should display a token count description (default=50)", () => {
+it("should display a token count description", () => {
   renderWithMemoryRouter(<TokensList />);
 
   expect(screen.getByText(new RegExp(`showing 2 out of 2 tokens`, "i"))).toBeInTheDocument();
@@ -67,3 +71,27 @@ it("disables the Export button if no rows are selected", async () => {
 
   expect(screen.getByRole("button", { name: "Export" })).not.toBeDisabled();
 });
+
+it("displays a notification after a successful single deletion", async () => {
+  renderWithMemoryRouter(<TokensList />);
+
+  await userEvent.click(screen.getAllByRole("checkbox")[1]);
+  await userEvent.click(screen.getByRole("button", { name: /delete/i }));
+
+  expect(
+    screen.getByRole("heading", {
+      name: /deleted/i,
+    }),
+  ).toBeInTheDocument();
+  expect(screen.getByText(/an enrollment token was deleted\./i)).toBeInTheDocument();
+});
+
+it("display a different notification for multiple deletions", async () => {
+  renderWithMemoryRouter(<TokensList />);
+  const checkboxes = screen.getAllByRole("checkbox");
+  await userEvent.click(checkboxes[1]);
+  await userEvent.click(checkboxes[2]);
+  await userEvent.click(screen.getByRole("button", { name: /delete/i }));
+
+  expect(screen.getByText(/2 enrollment tokens were deleted\./i)).toBeInTheDocument();
+});
diff --git a/frontend/src/components/TokensList/TokensList.tsx b/frontend/src/components/TokensList/TokensList.tsx
index 6bc8d28..cf2bcc0 100644
--- a/frontend/src/components/TokensList/TokensList.tsx
+++ b/frontend/src/components/TokensList/TokensList.tsx
@@ -1,6 +1,7 @@
 import { useState, useEffect } from "react";
 
-import { Accordion, Button, Col, Row } from "@canonical/react-components";
+import { Accordion, Button, Col, Row, Notification } from "@canonical/react-components";
+import pluralize from "pluralize";
 import { Link } from "react-router-dom";
 
 import TokensTable from "./components/TokensTable/TokensTable";
@@ -9,18 +10,36 @@ import docsUrls from "@/base/docsUrls";
 import ExternalLink from "@/components/ExternalLink";
 import PaginationBar from "@/components/base/PaginationBar";
 import { useAppContext } from "@/context";
-import { useTokensQuery } from "@/hooks/api";
+import { useDeleteTokensMutation, useTokensQuery } from "@/hooks/api";
 import usePagination from "@/hooks/usePagination";
 
 const DEFAULT_PAGE_SIZE = 50;
 
 const TokensList = () => {
-  const { setSidebar, rowSelection } = useAppContext();
+  const { setSidebar, rowSelection, setRowSelection } = useAppContext();
   const [totalDataCount, setTotalDataCount] = useState(0);
+  const [deleteNotification, setDeleteNotification] = useState("");
   const { page, debouncedPage, size, handleNextClick, handlePreviousClick, handlePageSizeChange, setPage } =
     usePagination(DEFAULT_PAGE_SIZE, totalDataCount);
 
-  const { data, isLoading, isFetchedAfterMount } = useTokensQuery({ page: `${debouncedPage}`, size: `${size}` });
+  const { data, isLoading, isFetchedAfterMount, isSuccess } = useTokensQuery({
+    page: `${debouncedPage}`,
+    size: `${size}`,
+  });
+  const selectedIds = isSuccess ? Object.keys(rowSelection).map((_, idx) => data.items[idx].id) : [];
+
+  const handleTokenDeleteSuccess = () => {
+    const deletedTokenCount = selectedIds.length;
+    setDeleteNotification(
+      `${deletedTokenCount === 1 ? "An" : ""} ${pluralize(
+        "enrollment token",
+        deletedTokenCount,
+        deletedTokenCount > 1,
+      )} ${deletedTokenCount === 1 ? "was" : "were"} deleted.`,
+    );
+    setRowSelection({});
+  };
+  const tokensDeleteMutation = useDeleteTokensMutation({ onSuccess: handleTokenDeleteSuccess });
 
   useEffect(() => {
     if (data && "total" in data) {
@@ -28,8 +47,19 @@ const TokensList = () => {
     }
   }, [data]);
 
+  const handleTokenDelete = () => tokensDeleteMutation.mutate(selectedIds);
+
   return (
     <section className="tokens-list">
+      {deleteNotification ? (
+        <Row>
+          <Col size={12}>
+            <Notification severity="information" title="Deleted">
+              {deleteNotification}
+            </Notification>
+          </Col>
+        </Row>
+      ) : null}
       <header className="tokens-list-header" id="tokens-list-header">
         <Row>
           <Col size={12}>
@@ -79,7 +109,7 @@ const TokensList = () => {
           <Col size={12}>
             <div className="u-flex u-flex--justify-end">
               <Button disabled={!Object.keys(rowSelection).length}>Export</Button>
-              <Button appearance="negative" disabled={!Object.keys(rowSelection).length}>
+              <Button appearance="negative" disabled={!Object.keys(rowSelection).length} onClick={handleTokenDelete}>
                 Delete
               </Button>
               <Button className="p-button--positive" onClick={() => setSidebar("createToken")} type="button">
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
index a4c2b15..feb942b 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
@@ -28,8 +28,14 @@ const TokensTable = ({
   isLoading,
 }: Pick<useTokensQueryResult, "data" | "isLoading" | "isFetchedAfterMount">) => {
   const [copiedText, setCopiedText] = useState("");
+  const { rowSelection, setRowSelection } = useAppContext();
   const isTokenCopied = useCallback((token: string) => token === copiedText, [copiedText]);
 
+  // clear table selection on unmount
+  useEffect(() => {
+    return () => setRowSelection({});
+  }, [setRowSelection]);
+
   const resetCopiedText = (timeout = 500) => {
     setTimeout(() => {
       setCopiedText("");
@@ -100,12 +106,6 @@ const TokensTable = ({
     ],
     [handleTokenCopy, isTokenCopied],
   );
-  const { rowSelection, setRowSelection } = useAppContext();
-
-  // clear selection on unmount
-  useEffect(() => {
-    return () => setRowSelection({});
-  }, [setRowSelection]);
 
   const noItems = useMemo<Token[]>(() => [], []);
 
diff --git a/frontend/src/hooks/api.ts b/frontend/src/hooks/api.ts
index 74d685f..0ab5537 100644
--- a/frontend/src/hooks/api.ts
+++ b/frontend/src/hooks/api.ts
@@ -10,6 +10,7 @@ import type {
   PostLoginData,
 } from "@/api/handlers";
 import {
+  deleteTokens,
   postLogin,
   patchEnrollmentRequests,
   getEnrollmentRequests,
@@ -41,6 +42,9 @@ export const useTokensQuery = ({ page, size }: GetTokensQueryParams) =>
 
 export const useTokensMutation = () => useMutation(postTokens);
 
+export const useDeleteTokensMutation = (options: UseMutationOptions<unknown, unknown, string[], unknown>) =>
+  useMutation(deleteTokens, options);
+
 export type UseEnrollmentRequestsQueryResult = ReturnType<typeof useRequestsQuery>;
 export const useRequestsQuery = ({ page, size }: GetEnrollmentRequestsQueryParams) =>
   useQuery<EnrollmentRequestsQueryResult>({
diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts
index 9b18bbb..ab41f1f 100644
--- a/frontend/src/mocks/browser.ts
+++ b/frontend/src/mocks/browser.ts
@@ -7,6 +7,7 @@ import {
   getEnrollmentRequests,
   patchEnrollmentRequests,
   postTokens,
+  deleteTokens,
 } from "./resolvers";
 
 export const worker = setupWorker(
@@ -16,4 +17,5 @@ export const worker = setupWorker(
   getEnrollmentRequests,
   patchEnrollmentRequests,
   getTokens,
+  deleteTokens,
 );
diff --git a/frontend/src/mocks/resolvers.ts b/frontend/src/mocks/resolvers.ts
index 385d828..e1f0325 100644
--- a/frontend/src/mocks/resolvers.ts
+++ b/frontend/src/mocks/resolvers.ts
@@ -79,6 +79,15 @@ export const createMockGetTokensResolver =
     return res(ctx.json(response));
   };
 
+type DeleteTokensResponseResolver = ResponseResolver<RestRequest, typeof restContext>;
+export const createMockDeleteTokensResolver = (): DeleteTokensResponseResolver => async (req, res, ctx) => {
+  const ids = req.body;
+  if (Array.isArray(ids) && ids.length > 0) {
+    return res(ctx.status(204));
+  }
+  return res(ctx.status(400));
+};
+
 export const createMockGetEnrollmentRequestsResolver =
   (enrollmentRequests = enrollmentRequestsList): TokensResponseResolver =>
   (req, res, ctx) => {
@@ -111,6 +120,7 @@ export const postLogin = rest.post(urls.login, createMockLoginResolver());
 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 deleteTokens = rest.delete(urls.tokens, createMockDeleteTokensResolver());
 export const getEnrollmentRequests = rest.get(urls.enrollmentRequests, createMockGetEnrollmentRequestsResolver());
 export const patchEnrollmentRequests = rest.patch(urls.enrollmentRequests, createMockPostEnrollmentRequestsResolver());
 export const allResolvers = [getSites, postTokens, getTokens, getEnrollmentRequests, patchEnrollmentRequests];

Follow ups