← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~petermakowski/maas-site-manager:wire-up-table-state-MAASENG-1415 into maas-site-manager:main

 

Peter Makowski has proposed merging ~petermakowski/maas-site-manager:wire-up-table-state-MAASENG-1415 into maas-site-manager:main.

Commit message:
wire up table state actions MAASENG-1415
- add remove regions side panel MAASENG-1498
- reorganise side panels to use context


Requested reviews:
  MAAS Committers (maas-committers)

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

select/deselect all will be refined as part of another task https://warthogs.atlassian.net/browse/MAASENG-1499

QA Steps
1. Go to /sites
2. Verify that remove button is disabled
3. Select a few machines
4. Click on "remove" button to open the form
5. Navigate to another page of results on the sites list
6. Select a few more machines
7. Make sure that the Delete form title updates accordingly
8. Make sure that the confirm button is disabled in the form
9. Type in the required confirmation string
10. Verify that the submit button has been enabled

-- 
Your team MAAS Committers is requested to review the proposed merge of ~petermakowski/maas-site-manager:wire-up-table-state-MAASENG-1415 into maas-site-manager:main.
diff --git a/.gitignore b/.gitignore
index c299531..6e203b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@ logs
 /frontend/node_modules
 /frontend/dist
 /frontend/dist-ssr
+/frontend/coverage
 *.local
 
 # dotrun
diff --git a/frontend/package.json b/frontend/package.json
index ebbd551..73901ff 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -46,6 +46,7 @@
     "@types/react-dom": "18.0.11",
     "@typescript-eslint/parser": "5.55.0",
     "@vitejs/plugin-react-swc": "3.2.0",
+    "@vitest/coverage-c8": "0.29.7",
     "abort-controller": "3.0.0",
     "chance": "1.1.11",
     "dotenv": "16.0.3",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2ce52bd..4ee3b92 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -2,6 +2,7 @@ import "./App.scss";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { createBrowserRouter, RouterProvider } from "react-router-dom";
 
+import { AppContextProvider } from "./context";
 import routes from "./routes";
 
 const queryClient = new QueryClient();
@@ -10,7 +11,9 @@ const router = createBrowserRouter(routes);
 const App: React.FC = () => {
   return (
     <QueryClientProvider client={queryClient}>
-      <RouterProvider router={router} />
+      <AppContextProvider>
+        <RouterProvider router={router} />
+      </AppContextProvider>
     </QueryClientProvider>
   );
 };
diff --git a/frontend/src/components/MainLayout/MainLayout.tsx b/frontend/src/components/MainLayout/MainLayout.tsx
index 5e0686c..d033495 100644
--- a/frontend/src/components/MainLayout/MainLayout.tsx
+++ b/frontend/src/components/MainLayout/MainLayout.tsx
@@ -1,13 +1,28 @@
-import { Col, Row } from "@canonical/react-components";
+import { useEffect } from "react";
+
+import { Col, Row, usePrevious } from "@canonical/react-components";
 import classNames from "classnames";
 import { Outlet, useLocation } from "react-router-dom";
 
 import "./MainLayout.scss";
 import Navigation from "@/components/Navigation";
+import RemoveRegions from "@/components/RemoveRegions";
+import { useAppContext } from "@/context";
 import TokensCreate from "@/pages/tokens/create";
 
-const Aside = ({ children, isOpen }: { children: React.ReactNode; isOpen: boolean }) => (
-  <aside className={classNames("l-aside", "is-maas-site-manager", { "is-collapsed": !isOpen })} id="aside-panel">
+export const sidebarLabels: Record<"removeRegions" | "createToken", string> = {
+  removeRegions: "Remove regions",
+  createToken: "Generate tokens",
+};
+
+const Aside = ({ children, isOpen, ...props }: { children: React.ReactNode; isOpen: boolean }) => (
+  <aside
+    aria-hidden={!isOpen}
+    className={classNames("l-aside", "is-maas-site-manager", { "is-collapsed": !isOpen })}
+    id="aside-panel"
+    role="dialog"
+    {...props}
+  >
     <Row>
       <Col size={12}>{children}</Col>
     </Row>
@@ -15,8 +30,16 @@ const Aside = ({ children, isOpen }: { children: React.ReactNode; isOpen: boolea
 );
 
 const MainLayout: React.FC = () => {
-  const { state, pathname } = useLocation();
-  const hasSidebar = !!state?.sidebar;
+  const { sidebar, setSidebar } = useAppContext();
+  const { pathname } = useLocation();
+  const previousPathname = usePrevious(pathname);
+
+  // close any open panels on route change
+  useEffect(() => {
+    if (pathname !== previousPathname) {
+      setSidebar(null);
+    }
+  }, [pathname, previousPathname, setSidebar]);
 
   return (
     <div className="l-application">
@@ -29,7 +52,13 @@ const MainLayout: React.FC = () => {
           </div>
         </div>
       </main>
-      <Aside isOpen={hasSidebar}>{hasSidebar && pathname === "/tokens" ? <TokensCreate /> : null}</Aside>
+      <Aside aria-label={sidebar ? sidebarLabels[sidebar] : undefined} isOpen={!!sidebar}>
+        {!!sidebar && sidebar === "createToken" ? (
+          <TokensCreate />
+        ) : !!sidebar && sidebar === "removeRegions" ? (
+          <RemoveRegions />
+        ) : null}
+      </Aside>
     </div>
   );
 };
diff --git a/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx b/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
new file mode 100644
index 0000000..48ffd54
--- /dev/null
+++ b/frontend/src/components/RemoveRegions/RemoveRegions.test.tsx
@@ -0,0 +1,30 @@
+import { vi } from "vitest";
+
+import RemoveRegions from "./index";
+
+import { render, screen, userEvent } from "@/test-utils";
+
+vi.mock("@/context", () => ({
+  useAppContext: () => ({
+    rowSelection: {
+      "1": true,
+      "2": true,
+    },
+  }),
+}));
+
+it("if the correct phrase has been entered the 'Remove' button becomes enabled.", async () => {
+  render(<RemoveRegions />);
+  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+  await userEvent.type(screen.getByRole("textbox"), "remove 2 regions");
+  expect(screen.queryByText(/Confirmation string is not correct/i)).not.toBeInTheDocument();
+  expect(screen.getByRole("button", { name: /Remove/i })).toBeEnabled();
+});
+
+it("if the confirmation string is not correct and the user unfoxuses the input field a error state is shown.", async () => {
+  render(<RemoveRegions />);
+  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+  await userEvent.type(screen.getByRole("textbox"), "incorrect string{tab}");
+  expect(screen.getByText(/Confirmation string is not correct/i)).toBeInTheDocument();
+  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+});
diff --git a/frontend/src/components/RemoveRegions/RemoveRegions.tsx b/frontend/src/components/RemoveRegions/RemoveRegions.tsx
new file mode 100644
index 0000000..d4fcaa8
--- /dev/null
+++ b/frontend/src/components/RemoveRegions/RemoveRegions.tsx
@@ -0,0 +1,101 @@
+import { useEffect } from "react";
+
+import { Button, Icon, Input, useId } from "@canonical/react-components";
+import { Field, Form, Formik } from "formik";
+import * as Yup from "yup";
+
+import { useAppContext } from "@/context";
+
+const initialValues = {
+  confirmText: "",
+};
+
+type RemoveRegionsFormValues = typeof initialValues;
+
+const RemoveRegionsFormSchema = Yup.object().shape({
+  confirmText: Yup.string()
+    .required()
+    .when("$expectedConfirmTextValue", (type, schema) => {
+      return schema.equals(type);
+    }),
+});
+
+const createHandleValidate =
+  ({ expectedConfirmTextValue }: { expectedConfirmTextValue: string }) =>
+  async (values: RemoveRegionsFormValues) => {
+    let errors = {};
+    await RemoveRegionsFormSchema.validate(values, { context: { expectedConfirmTextValue } }).catch((error) => {
+      errors = { confirmText: `Confirmation string is not correct. Expected ${expectedConfirmTextValue}` };
+    });
+    return errors;
+  };
+
+const RemoveRegions = () => {
+  const { rowSelection } = useAppContext();
+  const { setSidebar } = useAppContext();
+  const handleDeleteSites = () => {
+    // TODO: integrate with delete sites endpoint
+    setSidebar(null);
+  };
+  const regionsCount = rowSelection && Object.keys(rowSelection).length;
+  const id = useId();
+  const confirmTextId = `confirm-text-${id}`;
+  const headingId = `heading-${id}`;
+  const expectedConfirmTextValue = `remove ${regionsCount} regions`;
+  const handleSubmit = () => {
+    // TODO: integrate with delete regions endpoint
+  };
+
+  // close the sidebar when there are no regions selected
+  useEffect(() => {
+    if (!regionsCount) {
+      setSidebar(null);
+    }
+  }, [regionsCount, setSidebar]);
+
+  return (
+    <Formik<RemoveRegionsFormValues>
+      initialValues={initialValues}
+      onSubmit={handleSubmit}
+      validate={createHandleValidate({ expectedConfirmTextValue })}
+    >
+      {({ isSubmitting, errors, touched, isValid, dirty }) => (
+        <Form aria-labelledby={headingId} className="tokens-create" noValidate>
+          <div className="tokens-create">
+            <h3 className="tokens-create__heading p-heading--4" id={headingId}>
+              Remove <strong> {regionsCount} regions</strong> from Site Manager
+            </h3>
+            <p>
+              The deletion of data is irreversible. You can re-enrol the MAAS region again through the enrolment
+              process.
+            </p>
+            <p id={confirmTextId}>
+              Type <strong>remove {regionsCount} regions</strong> to confirm.
+            </p>
+            <Field
+              aria-labelledby={confirmTextId}
+              as={Input}
+              error={touched.confirmText && errors.confirmText}
+              name="confirmText"
+              placeholder={`remove ${regionsCount} regions`}
+              type="text"
+            />
+            <Button onClick={() => setSidebar(null)} type="button">
+              Cancel
+            </Button>
+            <Button
+              appearance="negative"
+              disabled={!dirty || !isValid || isSubmitting}
+              onClick={handleDeleteSites}
+              type="button"
+            >
+              <Icon light name="delete" /> Remove
+            </Button>
+          </div>
+        </Form>
+      )}
+    </Formik>
+  );
+};
+
+export default RemoveRegions;
diff --git a/frontend/src/components/RemoveRegions/index.ts b/frontend/src/components/RemoveRegions/index.ts
new file mode 100644
index 0000000..328f27f
--- /dev/null
+++ b/frontend/src/components/RemoveRegions/index.ts
@@ -0,0 +1 @@
+export { default } from "./RemoveRegions";
diff --git a/frontend/src/components/SitesList/SitesList.test.tsx b/frontend/src/components/SitesList/SitesList.test.tsx
index 428da92..97abb01 100644
--- a/frontend/src/components/SitesList/SitesList.test.tsx
+++ b/frontend/src/components/SitesList/SitesList.test.tsx
@@ -4,7 +4,7 @@ import urls from "@/api/urls";
 import { siteFactory } from "@/mocks/factories";
 import { createMockSitesResolver } from "@/mocks/resolvers";
 import { createMockGetServer } from "@/mocks/server";
-import { render, screen, waitFor, within } from "@/test-utils";
+import { render, renderWithMemoryRouter, screen, userEvent, waitFor, within } from "@/test-utils";
 
 const sites = siteFactory.buildList(2);
 const mockServer = createMockGetServer(urls.sites, createMockSitesResolver(sites));
@@ -37,3 +37,14 @@ it("displays populated sites table", async () => {
     .getAllByRole("row")
     .forEach((row, i) => expect(row).toHaveTextContent(new RegExp(sites[i].name, "i")));
 });
+
+it("remove button is disabled if no row is selected", async () => {
+  renderWithMemoryRouter(<SitesList />);
+  expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
+});
+
+it("remove button is enabled if a row is selected", async () => {
+  renderWithMemoryRouter(<SitesList />);
+  await userEvent.click(screen.getByRole("checkbox", { name: /select all/i }));
+  await waitFor(() => expect(screen.getByRole("button", { name: /Remove/i })).toBeEnabled());
+});
diff --git a/frontend/src/components/SitesList/components/SitesTable.scss b/frontend/src/components/SitesList/components/SitesTable.scss
index da82d6e..5543712 100644
--- a/frontend/src/components/SitesList/components/SitesTable.scss
+++ b/frontend/src/components/SitesList/components/SitesTable.scss
@@ -33,4 +33,15 @@
   .status-icon.is-unknown::before {
     color: #cdcdcd;
   }
+  .p-checkbox.is-label-visually-hidden .p-checkbox__label {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    padding: 0;
+    margin: -1px;
+    border: 0;
+    clip: rect(0 0 0 0);
+    overflow: hidden;
+    white-space: nowrap;
+  }
 }
diff --git a/frontend/src/components/SitesList/components/SitesTable.tsx b/frontend/src/components/SitesList/components/SitesTable.tsx
index b80b413..d325840 100644
--- a/frontend/src/components/SitesList/components/SitesTable.tsx
+++ b/frontend/src/components/SitesList/components/SitesTable.tsx
@@ -1,8 +1,7 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo } from "react";
 
-import { Input } from "@canonical/react-components";
 import { useReactTable, flexRender, getCoreRowModel } from "@tanstack/react-table";
-import type { ColumnDef, Column } from "@tanstack/react-table";
+import type { ColumnDef, Column, Getter, Row } from "@tanstack/react-table";
 import pick from "lodash/fp/pick";
 import useLocalStorageState from "use-local-storage-state";
 
@@ -11,9 +10,9 @@ import SitesTableControls from "./SitesTableControls";
 
 import type { SitesQueryResult } from "@/api/types";
 import { isDev } from "@/constants";
+import { useAppContext } from "@/context";
 import type { UseSitesQueryResult } from "@/hooks/api";
 import { getCountryName, getTimeByUTCOffset, getTimezoneUTCString } from "@/utils";
-
 import "./SitesTable.scss";
 
 const createAccessor =
@@ -37,35 +36,53 @@ const SitesTable = ({
     defaultValue: {},
   });
 
+  const { rowSelection, setRowSelection } = useAppContext();
+
+  // clear selection on unmount
+  useEffect(() => {
+    return () => setRowSelection({});
+  }, [setRowSelection]);
+
   const columns = useMemo<SitesColumnDef[]>(
     () => [
       {
         id: "select",
+        accessorKey: "name",
         header: ({ table }) => (
-          <div>
-            <Input
+          <label className="p-checkbox">
+            <input
+              aria-checked={table.getIsSomeRowsSelected() || table.getIsSomePageRowsSelected() ? "mixed" : undefined}
+              aria-label="select all"
+              className="p-checkbox__input"
               type="checkbox"
               {...{
-                checked: table.getIsAllRowsSelected(),
-                indeterminate: table.getIsSomeRowsSelected(),
-                onChange: table.getToggleAllRowsSelectedHandler(),
+                checked:
+                  table.getIsSomePageRowsSelected() ||
+                  table.getIsSomeRowsSelected() ||
+                  table.getIsAllPageRowsSelected(),
+                onChange: table.getToggleAllPageRowsSelectedHandler(),
               }}
             />
-          </div>
-        ),
-        cell: ({ row }) => (
-          <div>
-            <Input
-              type="checkbox"
-              {...{
-                checked: row.getIsSelected(),
-                disabled: !row.getCanSelect(),
-                indeterminate: row.getIsSomeSelected(),
-                onChange: row.getToggleSelectedHandler(),
-              }}
-            />
-          </div>
+            <span className="p-checkbox__label" />
+          </label>
         ),
+        cell: ({ row, getValue }: { row: Row<Site>; getValue: Getter<Site["name"]> }) => {
+          return (
+            <label className="p-checkbox">
+              <input
+                aria-label={getValue()}
+                className="p-checkbox__input"
+                type="checkbox"
+                {...{
+                  checked: row.getIsSelected(),
+                  disabled: !row.getCanSelect(),
+                  onChange: row.getToggleSelectedHandler(),
+                }}
+              />
+              <span className="p-checkbox__label" />
+            </label>
+          );
+        },
       },
       {
         id: "name",
@@ -164,19 +181,28 @@ const SitesTable = ({
     [],
   );
 
-  const [rowSelection, setRowSelection] = useState({});
   // wrap the empty array in useMemo to avoid re-rendering the empty table on every render
   const noItems = useMemo<Site[]>(() => [], []);
-
+  const pageCount = data && "total" in data ? Math.ceil(data.total / data.size) : 0;
+  const pageIndex = data && "page" in data ? data.page : 0;
+  const pageSize = data && "size" in data ? data.size : 0;
   const table = useReactTable<Site>({
     data: data?.items || noItems,
     columns,
     state: {
       rowSelection,
       columnVisibility,
+      pagination: {
+        pageIndex,
+        pageSize,
+      },
     },
+    getRowId: (row) => row.identifier,
+    manualPagination: true,
+    pageCount,
     onColumnVisibilityChange: setColumnVisibility,
     enableRowSelection: true,
+    enableMultiRowSelection: true,
     onRowSelectionChange: setRowSelection,
     enableColumnResizing: false,
     columnResizeMode: "onChange",
diff --git a/frontend/src/components/SitesList/components/SitesTableControls.tsx b/frontend/src/components/SitesList/components/SitesTableControls.tsx
index 24b8706..76f1831 100644
--- a/frontend/src/components/SitesList/components/SitesTableControls.tsx
+++ b/frontend/src/components/SitesList/components/SitesTableControls.tsx
@@ -1,9 +1,10 @@
-import { Row, Col, SearchBox } from "@canonical/react-components";
+import { Row, Col, SearchBox, Button, Icon } from "@canonical/react-components";
 
 import ColumnsVisibilityControl from "./ColumnsVisibilityControl";
 import SitesCount from "./SitesCount";
 import type { SitesColumn } from "./SitesTable";
 
+import { useAppContext } from "@/context";
 import type { UseSitesQueryResult } from "@/hooks/api";
 
 const SitesTableControls = ({
@@ -18,6 +19,8 @@ const SitesTableControls = ({
   const handleSearchInput = (inputValue: string) => {
     setSearchText(inputValue);
   };
+  const { rowSelection, setSidebar } = useAppContext();
+  const isRemoveDisabled = Object.keys(rowSelection).length <= 0;
 
   return (
     <Row>
@@ -26,10 +29,20 @@ const SitesTableControls = ({
           <SitesCount data={data} isLoading={isLoading} />
         </h2>
       </Col>
-      <Col size={8}>
+      <Col size={6}>
         <SearchBox externallyControlled onChange={handleSearchInput} placeholder="Search and filter" />
       </Col>
       <Col className="u-flex u-flex--align-end u-flex--column" size={2}>
+        <Button
+          appearance="negative"
+          disabled={isRemoveDisabled}
+          onClick={() => setSidebar("removeRegions")}
+          type="button"
+        >
+          <Icon light name="delete" /> Remove
+        </Button>
+      </Col>
+      <Col className="u-flex u-flex--align-end u-flex--column" size={2}>
         <ColumnsVisibilityControl columns={allColumns} />
       </Col>
     </Row>
diff --git a/frontend/src/components/TokensCreate/TokensCreate.tsx b/frontend/src/components/TokensCreate/TokensCreate.tsx
index 906449a..f055ce5 100644
--- a/frontend/src/components/TokensCreate/TokensCreate.tsx
+++ b/frontend/src/components/TokensCreate/TokensCreate.tsx
@@ -2,12 +2,12 @@ import { useId } from "react";
 
 import { Button, Input, Label, Notification } from "@canonical/react-components";
 import { Field, Formik, Form } from "formik";
-import { Link, useNavigate } from "react-router-dom";
 import * as Yup from "yup";
 
 import "./TokensCreate.scss";
 import { humanIntervalToISODuration } from "./utils";
 
+import { useAppContext } from "@/context";
 import { useTokensMutation } from "@/hooks/api";
 
 const initialValues = {
@@ -41,8 +41,8 @@ const TokensCreate = () => {
   const headingId = useId();
   const expiresId = useId();
   const amountId = useId();
-  const navigate = useNavigate();
   const tokensMutation = useTokensMutation();
+  const { setSidebar } = useAppContext();
   const handleSubmit = async (
     { amount, expires }: TokensCreateFormValues,
     { setSubmitting }: { setSubmitting: (isSubmitting: boolean) => void },
@@ -54,7 +54,7 @@ const TokensCreate = () => {
     // TODO: update the tokens list once fetching tokens from API is implemented
     // https://warthogs.atlassian.net/browse/MAASENG-1474
     setSubmitting(false);
-    navigate("/tokens", { state: { sidebar: false } });
+    setSidebar(null);
   };
 
   return (
@@ -92,9 +92,9 @@ const TokensCreate = () => {
             </p>
             <hr className="tokens-create__separator" />
             <div className="tokens-create__buttons">
-              <Link className="p-button" role="button" state={{ sidebar: false }} to="tokens">
+              <Button onClick={() => setSidebar(null)} type="button">
                 Cancel
-              </Link>
+              </Button>
               <Button
                 appearance="positive"
                 disabled={!dirty || !isValid || tokensMutation.isLoading || isSubmitting}
diff --git a/frontend/src/components/TokensList/TokensList.tsx b/frontend/src/components/TokensList/TokensList.tsx
index 9e56656..31a7f4f 100644
--- a/frontend/src/components/TokensList/TokensList.tsx
+++ b/frontend/src/components/TokensList/TokensList.tsx
@@ -1,7 +1,10 @@
 import { Button, Col, Row } from "@canonical/react-components";
-import { Link } from "react-router-dom";
+
+import { useAppContext } from "@/context";
 
 const TokensList = () => {
+  const { setSidebar } = useAppContext();
+
   return (
     <section>
       <Row>
@@ -14,9 +17,9 @@ const TokensList = () => {
           <div className="u-flex u-flex--justify-end">
             <Button>Export</Button>
             <Button appearance="negative">Delete</Button>
-            <Link className="p-button--positive" role="button" state={{ sidebar: true }} to="">
+            <Button className="p-button--positive" onClick={() => setSidebar("createToken")} type="button">
               Generate tokens
-            </Link>
+            </Button>
           </div>
         </Col>
       </Row>
diff --git a/frontend/src/context.tsx b/frontend/src/context.tsx
new file mode 100644
index 0000000..c7c41ec
--- /dev/null
+++ b/frontend/src/context.tsx
@@ -0,0 +1,26 @@
+import { createContext, useContext, useState } from "react";
+
+import type { OnChangeFn, RowSelectionState } from "@tanstack/react-table";
+
+export const AppContext = createContext<{
+  rowSelection: RowSelectionState;
+  setRowSelection: OnChangeFn<RowSelectionState>;
+  sidebar: "removeRegions" | "createToken" | null;
+  setSidebar: (sidebar: "removeRegions" | "createToken" | null) => void;
+}>({
+  rowSelection: {},
+  setRowSelection: () => ({}),
+  sidebar: null,
+  setSidebar: () => null,
+});
+
+export const AppContextProvider = ({ children }: { children: React.ReactNode }) => {
+  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
+  const [sidebar, setSidebar] = useState<"removeRegions" | "createToken" | null>(null);
+
+  return (
+    <AppContext.Provider value={{ rowSelection, setRowSelection, sidebar, setSidebar }}>{children}</AppContext.Provider>
+  );
+};
+
+export const useAppContext = () => useContext(AppContext);
diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx
index 450b8d7..f949896 100644
--- a/frontend/src/test-utils.tsx
+++ b/frontend/src/test-utils.tsx
@@ -7,6 +7,8 @@ import { render } from "@testing-library/react";
 import type { MemoryRouterProps } from "react-router-dom";
 import { MemoryRouter } from "react-router-dom";
 
+import { AppContextProvider } from "./context";
+
 const queryClient = new QueryClient({
   defaultOptions: {
     queries: {
@@ -24,9 +26,11 @@ const makeProvidersWithMemoryRouter =
   (memoryRouterProps: MemoryRouterProps) =>
   ({ children }: { children: React.ReactNode }) => {
     return (
-      <QueryClientProvider client={queryClient}>
-        <MemoryRouter {...memoryRouterProps}>{children}</MemoryRouter>
-      </QueryClientProvider>
+      <Providers>
+        <MemoryRouter {...memoryRouterProps}>
+          <AppContextProvider>{children}</AppContextProvider>
+        </MemoryRouter>
+      </Providers>
     );
   };
 
diff --git a/frontend/tests/sites.spec.ts b/frontend/tests/sites.spec.ts
index 072e2fa..c7420c4 100644
--- a/frontend/tests/sites.spec.ts
+++ b/frontend/tests/sites.spec.ts
@@ -24,3 +24,10 @@ test("can hide table columns", async ({ page }) => {
   expect(refreshedColumnHeaders).toHaveCount(columnsCount - 1);
   await expect(refreshedColumnHeaders).toHaveText(["", /name/i, /country/i, /local time/i, /machines/i]);
 });
+
+test("opens remove regions panel if remove button is pressed", async ({ page }) => {
+  await expect(page.getByRole("dialog", { name: /Remove regions/i })).not.toBeVisible();
+  await page.getByRole("checkbox", { name: /select all/i }).click({ force: true });
+  await page.getByRole("button", { name: /Remove/i }).click();
+  await expect(page.getByRole("dialog", { name: /Remove regions/i })).toBeVisible();
+});
diff --git a/frontend/tests/tokens.spec.ts b/frontend/tests/tokens.spec.ts
index df604cc..778602c 100644
--- a/frontend/tests/tokens.spec.ts
+++ b/frontend/tests/tokens.spec.ts
@@ -11,7 +11,7 @@ test("can open and close token generate form", async ({ page }) => {
   await expect(page.getByRole("form", { name: /Generate new enrollment tokens/i })).toBeHidden();
 });
 
-test("token create form persists its open state when navigating back", async ({ page }) => {
+test("token create form is closed when navigating away", async ({ page }) => {
   await page.getByRole("button", { name: /Generate tokens/i }).click();
   await expect(page.getByRole("form", { name: /Generate new enrollment tokens/i })).toBeVisible();
 
@@ -30,7 +30,7 @@ test("token create form persists its open state when navigating back", async ({ 
     .click();
 
   await page.goBack();
-  await expect(page.getByRole("form", { name: /Generate new enrollment tokens/i })).toBeVisible();
+  await expect(page.getByRole("form", { name: /Generate new enrollment tokens/i })).toBeHidden();
 });
 
 test("closes and clears the form after creating the token", async ({ page }) => {
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 1bd277a..fd9102f 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1058,6 +1058,11 @@
     "@babel/helper-validator-identifier" "^7.19.1"
     to-fast-properties "^2.0.0"
 
+"@bcoe/v8-coverage@^0.2.3":
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39";
+  integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
+
 "@canonical/cookie-policy@3.4.0":
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/@canonical/cookie-policy/-/cookie-policy-3.4.0.tgz#0d6708da340df5867fd2cc9dbd95538c46f20cf8";
@@ -1355,6 +1360,11 @@
   resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45";
   integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
 
+"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98";
+  integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==
+
 "@jest/expect-utils@^29.4.3":
   version "29.4.3"
   resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.4.3.tgz#95ce4df62952f071bcd618225ac7c47eaa81431e";
@@ -1413,7 +1423,7 @@
   resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24";
   integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
 
-"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9":
+"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9":
   version "0.3.17"
   resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985";
   integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==
@@ -1750,7 +1760,7 @@
   resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2";
   integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==
 
-"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1":
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44";
   integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
@@ -2067,6 +2077,15 @@
   dependencies:
     "@swc/core" "^1.3.35"
 
+"@vitest/coverage-c8@0.29.7":
+  version "0.29.7"
+  resolved "https://registry.yarnpkg.com/@vitest/coverage-c8/-/coverage-c8-0.29.7.tgz#ef2ce02ffb6c1645740139a62d1867b521bf1b95";
+  integrity sha512-TSubtP9JFBuI/wuApxwknHe40VDkX8hFbBak0OXj4/jCeXrEu5B5GPWcxzyk9YvzXgCaDvoiZV79I7AvhNI9YQ==
+  dependencies:
+    c8 "^7.13.0"
+    picocolors "^1.0.0"
+    std-env "^3.3.1"
+
 "@vitest/expect@0.29.3":
   version "0.29.3"
   resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.29.3.tgz#4b101ebcbaed608b20c2592cb7d833eb38e5bfa0";
@@ -2476,6 +2495,24 @@ builtins@^5.0.0:
   dependencies:
     semver "^7.0.0"
 
+c8@^7.13.0:
+  version "7.13.0"
+  resolved "https://registry.yarnpkg.com/c8/-/c8-7.13.0.tgz#a2a70a851278709df5a9247d62d7f3d4bcb5f2e4";
+  integrity sha512-/NL4hQTv1gBL6J6ei80zu3IiTrmePDKXKXOTLpHvcIWZTVYQlDhVWjjWvkhICylE8EwwnMVzDZugCvdx0/DIIA==
+  dependencies:
+    "@bcoe/v8-coverage" "^0.2.3"
+    "@istanbuljs/schema" "^0.1.3"
+    find-up "^5.0.0"
+    foreground-child "^2.0.0"
+    istanbul-lib-coverage "^3.2.0"
+    istanbul-lib-report "^3.0.0"
+    istanbul-reports "^3.1.4"
+    rimraf "^3.0.2"
+    test-exclude "^6.0.0"
+    v8-to-istanbul "^9.0.0"
+    yargs "^16.2.0"
+    yargs-parser "^20.2.9"
+
 cac@^6.7.14:
   version "6.7.14"
   resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959";
@@ -2624,6 +2661,15 @@ cli-width@^3.0.0:
   resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6";
   integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==
 
+cliui@^7.0.2:
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f";
+  integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==
+  dependencies:
+    string-width "^4.2.0"
+    strip-ansi "^6.0.0"
+    wrap-ansi "^7.0.0"
+
 cliui@^8.0.1:
   version "8.0.1"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa";
@@ -2679,7 +2725,7 @@ confusing-browser-globals@^1.0.11:
   resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81";
   integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==
 
-convert-source-map@^1.7.0:
+convert-source-map@^1.6.0, convert-source-map@^1.7.0:
   version "1.9.0"
   resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f";
   integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
@@ -2717,7 +2763,7 @@ cosmiconfig@^8.0.0:
     parse-json "^5.0.0"
     path-type "^4.0.0"
 
-cross-spawn@^7.0.2, cross-spawn@^7.0.3:
+cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6";
   integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
@@ -3577,6 +3623,14 @@ for-each@^0.3.3:
   dependencies:
     is-callable "^1.1.3"
 
+foreground-child@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53";
+  integrity sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==
+  dependencies:
+    cross-spawn "^7.0.0"
+    signal-exit "^3.0.2"
+
 form-data@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452";
@@ -3699,7 +3753,7 @@ glob-parent@^6.0.2:
   dependencies:
     is-glob "^4.0.3"
 
-glob@^7.1.3:
+glob@^7.1.3, glob@^7.1.4:
   version "7.2.3"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b";
   integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
@@ -3879,6 +3933,11 @@ html-encoding-sniffer@^3.0.0:
   dependencies:
     whatwg-encoding "^2.0.0"
 
+html-escaper@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453";
+  integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
+
 http-proxy-agent@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43";
@@ -4241,6 +4300,28 @@ isexe@^2.0.0:
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10";
   integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
 
+istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3";
+  integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==
+
+istanbul-lib-report@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#7518fe52ea44de372f460a76b5ecda9ffb73d8a6";
+  integrity sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==
+  dependencies:
+    istanbul-lib-coverage "^3.0.0"
+    make-dir "^3.0.0"
+    supports-color "^7.1.0"
+
+istanbul-reports@^3.1.4:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae";
+  integrity sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==
+  dependencies:
+    html-escaper "^2.0.0"
+    istanbul-lib-report "^3.0.0"
+
 jest-diff@^27.5.1:
   version "27.5.1"
   resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-27.5.1.tgz#a07f5011ac9e6643cf8a95a462b7b1ecf6680def";
@@ -4566,6 +4647,13 @@ magic-string@^0.30.0:
   dependencies:
     "@jridgewell/sourcemap-codec" "^1.4.13"
 
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f";
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
 map-obj@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d";
@@ -4629,7 +4717,7 @@ min-indent@^1.0.0:
   resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869";
   integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
-minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
+minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b";
   integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
@@ -5581,7 +5669,7 @@ scule@^1.0.0:
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7";
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
 
-semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
+semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
   version "6.3.0"
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d";
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
@@ -5856,6 +5944,15 @@ tapable@^2.2.0:
   resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0";
   integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==
 
+test-exclude@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e";
+  integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==
+  dependencies:
+    "@istanbuljs/schema" "^0.1.2"
+    glob "^7.1.4"
+    minimatch "^3.0.4"
+
 text-table@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4";
@@ -6207,6 +6304,15 @@ util@^0.12.3:
     is-typed-array "^1.1.3"
     which-typed-array "^1.1.2"
 
+v8-to-istanbul@^9.0.0:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.1.0.tgz#1b83ed4e397f58c85c266a570fc2558b5feb9265";
+  integrity sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==
+  dependencies:
+    "@jridgewell/trace-mapping" "^0.3.12"
+    "@types/istanbul-lib-coverage" "^2.0.1"
+    convert-source-map "^1.6.0"
+
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a";
@@ -6480,7 +6586,7 @@ yaml@1.10.2, yaml@^1.10.0, yaml@^1.10.2:
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b";
   integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
 
-yargs-parser@^20.2.3:
+yargs-parser@^20.2.2, yargs-parser@^20.2.3, yargs-parser@^20.2.9:
   version "20.2.9"
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee";
   integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
@@ -6490,6 +6596,19 @@ yargs-parser@^21.1.1:
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35";
   integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
 
+yargs@^16.2.0:
+  version "16.2.0"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66";
+  integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
+  dependencies:
+    cliui "^7.0.2"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.0"
+    y18n "^5.0.5"
+    yargs-parser "^20.2.2"
+
 yargs@^17.0.0, yargs@^17.3.1:
   version "17.6.2"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.6.2.tgz#2e23f2944e976339a1ee00f18c77fedee8332541";

Follow ups