sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #07325
[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