← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~nickdv99/maas-site-manager:frontend-qa-fixes-MAASENG-1543 into maas-site-manager:main

 

Nick De Villiers has proposed merging ~nickdv99/maas-site-manager:frontend-qa-fixes-MAASENG-1543 into maas-site-manager:main.

Commit message:
fix: Frontend QA fixes MAASENG-1543

Requested reviews:
  MAAS Committers (maas-committers)

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

- Added tooltip for expiration time
- Added tooltip for connection status
- Fixed behaviour of hidden columns select (added select all, added hidden count, removed "name")
- Change MAAS logo to machines icon for Regions in nav
- Added placeholders to token generation form
- Disable the export/delete token buttons if no tokens are selected

QA:

- Ensure that the icon for "regions" in the nav is the machines icon and not the maas logo
- Go to /regions
- Hover over a connection
- Ensure the right message is displayed (see https://zpl.io/z8GKLeM for reference)
- Click on "columns"
- Ensure you can select/deselect columns and the count updates
- Ensure the select all button works
- Go to /tokens
- Ensure the "export" and "delete" buttons are disabled
- Select a few tokens
- Ensure the aforementioned buttons are now enabled
- Hover over the expiration time for a token
- Ensure the expiration time is shown in a tooltip
- Click "Generate tokens"
- Ensure placeholders are visible in the form fields

Fixes MAASENG-1560
Fixes MAASENG-1563
Fixes MAASENG-1564
Fixes MAASENG-1565
-- 
Your team MAAS Committers is requested to review the proposed merge of ~nickdv99/maas-site-manager:frontend-qa-fixes-MAASENG-1543 into maas-site-manager:main.
diff --git a/frontend/src/App.scss b/frontend/src/App.scss
index 2d7748f..ea6f28a 100644
--- a/frontend/src/App.scss
+++ b/frontend/src/App.scss
@@ -33,6 +33,7 @@
 @include vf-p-icon-status-waiting-small;
 @include vf-p-icon-status-succeeded-small;
 @include vf-p-icon-settings;
+@include vf-p-icon-machines;
 
 // Utilities
 @include vf-u-text-muted;
diff --git a/frontend/src/base/docsUrls.ts b/frontend/src/base/docsUrls.ts
index 16a46db..16ccd8a 100644
--- a/frontend/src/base/docsUrls.ts
+++ b/frontend/src/base/docsUrls.ts
@@ -1,5 +1,6 @@
 const docsUrls = {
   enrollmentRequest: "",
+  troubleshooting: "",
 };
 
 export default docsUrls;
diff --git a/frontend/src/components/Navigation/Navigation.tsx b/frontend/src/components/Navigation/Navigation.tsx
index a61ee34..1ffec0d 100644
--- a/frontend/src/components/Navigation/Navigation.tsx
+++ b/frontend/src/components/Navigation/Navigation.tsx
@@ -12,7 +12,7 @@ export const navItems: NavLink[] = [
   {
     label: "Regions",
     url: "/sites",
-    icon: "maas",
+    icon: "machines",
   },
 ];
 
diff --git a/frontend/src/components/SitesList/SitesList.test.tsx b/frontend/src/components/SitesList/SitesList.test.tsx
index 06f4ab7..4fb4b4a 100644
--- a/frontend/src/components/SitesList/SitesList.test.tsx
+++ b/frontend/src/components/SitesList/SitesList.test.tsx
@@ -38,13 +38,55 @@ it("displays populated sites table", async () => {
     .forEach((row, i) => expect(row).toHaveTextContent(new RegExp(sites[i].name, "i")));
 });
 
-it("remove button is disabled if no row is selected", async () => {
+it("disables the 'remove' button if no rows are selected", async () => {
   renderWithMemoryRouter(<SitesList />);
   expect(screen.getByRole("button", { name: /Remove/i })).toBeDisabled();
 });
 
-it("remove button is enabled if some rows are selected", async () => {
+it("enables the 'remove' button if some rows are selected", async () => {
   renderWithMemoryRouter(<SitesList />);
   await userEvent.click(screen.getByRole("checkbox", { name: /select all/i }));
   await waitFor(() => expect(screen.getByRole("button", { name: /Remove/i })).toBeEnabled());
 });
+
+it("can hide and unhide columns", async () => {
+  renderWithMemoryRouter(<SitesList />);
+  expect(screen.getByRole("columnheader", { name: /Connection/i })).toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole("button", { name: "Columns" }));
+  await userEvent.click(screen.getByRole("checkbox", { name: "Connection" }));
+
+  expect(screen.getByRole("checkbox", { name: "3 out of 4 selected" })).toBeInTheDocument();
+  expect(screen.queryByRole("columnheader", { name: /Connection/i })).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole("checkbox", { name: "Connection" }));
+
+  expect(screen.getByRole("checkbox", { name: "4 out of 4 selected" })).toBeInTheDocument();
+  expect(screen.getByRole("columnheader", { name: /Connection/i })).toBeInTheDocument();
+});
+
+it("can hide and unhide all columns", async () => {
+  renderWithMemoryRouter(<SitesList />);
+
+  expect(screen.getByRole("columnheader", { name: /Connection/i })).toBeInTheDocument();
+  expect(screen.getByRole("columnheader", { name: /Country/i })).toBeInTheDocument();
+  expect(screen.getByRole("columnheader", { name: /Local time/i })).toBeInTheDocument();
+  expect(screen.getByRole("columnheader", { name: /Machines/i })).toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole("button", { name: "Columns" }));
+  await userEvent.click(screen.getByRole("checkbox", { name: "4 out of 4 selected" }));
+
+  expect(screen.getByRole("checkbox", { name: "0 out of 4 selected" })).toBeInTheDocument();
+
+  expect(screen.queryByRole("columnheader", { name: /Connection/i })).not.toBeInTheDocument();
+  expect(screen.queryByRole("columnheader", { name: /Country/i })).not.toBeInTheDocument();
+  expect(screen.queryByRole("columnheader", { name: /Local time/i })).not.toBeInTheDocument();
+  expect(screen.queryByRole("columnheader", { name: /Machines/i })).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByRole("checkbox", { name: "0 out of 4 selected" }));
+
+  expect(screen.getByRole("columnheader", { name: /Connection/i })).toBeInTheDocument();
+  expect(screen.getByRole("columnheader", { name: /Country/i })).toBeInTheDocument();
+  expect(screen.getByRole("columnheader", { name: /Local time/i })).toBeInTheDocument();
+  expect(screen.getByRole("columnheader", { name: /Machines/i })).toBeInTheDocument();
+});
diff --git a/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx b/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx
index 5cac133..eadb2ac 100644
--- a/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx
+++ b/frontend/src/components/SitesList/SitesTable/ConnectionInfo/ConnectionInfo.tsx
@@ -1,7 +1,10 @@
+import { Tooltip } from "@canonical/react-components";
 import classNames from "classnames";
 import get from "lodash/get";
 
 import type { Site } from "@/api/types";
+import docsUrls from "@/base/docsUrls";
+import ExternalLink from "@/components/ExternalLink";
 
 export const connectionIcons: Record<Site["connection"], string> = {
   stable: "is-stable",
@@ -17,11 +20,26 @@ export const connectionLabels: Record<Site["connection"], string> = {
 type ConnectionInfoProps = { connection: Site["connection"]; lastSeen?: Site["last_seen"] };
 
 const ConnectionInfo = ({ connection, lastSeen }: ConnectionInfoProps) => (
-  <>
+  <Tooltip
+    message={
+      connection === "unknown" ? (
+        "Haven't received a heartbeat from this region yet"
+      ) : connection === "stable" ? (
+        "Received a heartbeat in the expecteed interval of 5 minutes"
+      ) : (
+        <>
+          Haven't received a heartbeat in the expected interval of 5 minutes.
+          <br />
+          <ExternalLink to={docsUrls.troubleshooting}>Check the documentation for troubleshooting steps.</ExternalLink>
+        </>
+      )
+    }
+    position="btm-center"
+  >
     <div className={classNames("connection__text", "status-icon", get(connectionIcons, connection))}>
       {get(connectionLabels, connection)}
     </div>
     <div className="connection__text u-text--muted">{lastSeen}</div>
-  </>
+  </Tooltip>
 );
 export default ConnectionInfo;
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/ColumnsVisibilityControl.tsx b/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/ColumnsVisibilityControl.tsx
index d3518f4..07a8ac4 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/ColumnsVisibilityControl.tsx
+++ b/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/ColumnsVisibilityControl.tsx
@@ -1,8 +1,21 @@
 import { ContextualMenu, Icon, CheckboxInput } from "@canonical/react-components";
 
 import type { SitesColumn } from "@/components/SitesList/SitesTable/SitesTable";
+import { capitaliseFirst } from "@/utils/capitaliseFirst";
 
 function ColumnsVisibilityControl({ columns }: { columns: SitesColumn[] }) {
+  const filteredColumns = columns.filter((column) => column.id !== "select" && column.id !== "name");
+  const hiddenColumns = filteredColumns.filter((column) => column.getIsVisible() === false);
+  const selectedColumnsLength = filteredColumns.length - hiddenColumns.length;
+  const someColumnsChecked = selectedColumnsLength > 0 && selectedColumnsLength < filteredColumns.length;
+
+  const handleToggleAll = (isChecked: boolean) => {
+    filteredColumns
+      // If the "select all" checkbox is checked, find all hidden columns. Otherwise, find all visible columns
+      .filter((column) => (isChecked ? column.getIsVisible() === false : column.getIsVisible() === true))
+      .forEach((column) => column.toggleVisibility());
+  };
+
   return (
     <ContextualMenu
       className="filter-accordion"
@@ -18,22 +31,33 @@ function ColumnsVisibilityControl({ columns }: { columns: SitesColumn[] }) {
       toggleLabelFirst={true}
     >
       <div className="columns-visibility-select-wrapper u-no-padding--top">
-        {columns
-          .filter((column) => column.id !== "select")
-          .map((column) => {
-            return (
-              <div className="columns-visibility-checkbox" key={column.id}>
-                <CheckboxInput
-                  aria-label={column.id}
-                  label={column.id}
-                  {...{
-                    checked: column.getIsVisible(),
-                    onChange: column.getToggleVisibilityHandler(),
-                  }}
-                />
-              </div>
-            );
-          })}
+        <div className="columns-visibility-checkbox">
+          <CheckboxInput
+            checked={hiddenColumns.length === 0}
+            indeterminate={someColumnsChecked}
+            label={`${selectedColumnsLength} out of ${filteredColumns.length} selected`}
+            onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
+              handleToggleAll(e.target.checked);
+            }}
+          />
+        </div>
+      </div>
+      <hr />
+      <div className="columns-visibility-select-wrapper u-no-padding--top">
+        {filteredColumns.map((column) => {
+          return (
+            <div className="columns-visibility-checkbox" key={column.id}>
+              <CheckboxInput
+                aria-label={column.id}
+                label={capitaliseFirst(column.id)}
+                {...{
+                  checked: column.getIsVisible(),
+                  onChange: column.getToggleVisibilityHandler(),
+                }}
+              />
+            </div>
+          );
+        })}
       </div>
     </ContextualMenu>
   );
diff --git a/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/_ColumnsVisibilityControl.scss b/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/_ColumnsVisibilityControl.scss
index d3aa038..98cbc27 100644
--- a/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/_ColumnsVisibilityControl.scss
+++ b/frontend/src/components/SitesList/SitesTable/SitesTableControls/ColumnsVisibilityControl/_ColumnsVisibilityControl.scss
@@ -4,9 +4,8 @@
   }
   label {
     padding: 0 $sph--large $sph--x-small $sph--large;
-    text-transform: capitalize;
     width: 100%;
-    text-indent: 0;
+    text-indent: 0
   }
 }
 
@@ -15,7 +14,6 @@
   padding-left: $spv--small;
   text-align: left;
   width: 100%;
-  text-transform: capitalize;
 
   i[class*="p-icon"]:last-child {
     margin-left: 0;
diff --git a/frontend/src/components/TokensCreate/TokensCreate.tsx b/frontend/src/components/TokensCreate/TokensCreate.tsx
index 59bc94a..2443c7b 100644
--- a/frontend/src/components/TokensCreate/TokensCreate.tsx
+++ b/frontend/src/components/TokensCreate/TokensCreate.tsx
@@ -73,6 +73,7 @@ const TokensCreate = () => {
               error={touched.amount && errors.amount}
               id={amountId}
               name="amount"
+              placeholder="1"
               required
               type="text"
             />
@@ -82,6 +83,7 @@ const TokensCreate = () => {
               error={touched.expires && errors.expires}
               id={expiresId}
               name="expires"
+              placeholder="12 hours"
               required
               type="text"
             />
diff --git a/frontend/src/components/TokensList/TokensList.test.tsx b/frontend/src/components/TokensList/TokensList.test.tsx
index c055f8c..631096a 100644
--- a/frontend/src/components/TokensList/TokensList.test.tsx
+++ b/frontend/src/components/TokensList/TokensList.test.tsx
@@ -6,7 +6,7 @@ import urls from "@/api/urls";
 import { tokenFactory } from "@/mocks/factories";
 import { createMockGetTokensResolver } from "@/mocks/resolvers";
 import { createMockGetServer } from "@/mocks/server";
-import { screen, renderWithMemoryRouter, within } from "@/test-utils";
+import { screen, renderWithMemoryRouter, within, userEvent } from "@/test-utils";
 
 const tokens = tokenFactory.buildList(2);
 const mockServer = createMockGetServer(urls.tokens, createMockGetTokensResolver(tokens));
@@ -47,3 +47,23 @@ it("should display a token count description (default=50)", () => {
 
   expect(screen.getByText(new RegExp(`showing 2 out of 2 tokens`, "i"))).toBeInTheDocument();
 });
+
+it("disables the Delete button if no rows are selected", async () => {
+  renderWithMemoryRouter(<TokensList />);
+
+  expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
+
+  await userEvent.click(screen.getAllByRole("checkbox")[0]);
+
+  expect(screen.getByRole("button", { name: "Delete" })).not.toBeDisabled();
+});
+
+it("disables the Export button if no rows are selected", async () => {
+  renderWithMemoryRouter(<TokensList />);
+
+  expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
+
+  await userEvent.click(screen.getAllByRole("checkbox")[0]);
+
+  expect(screen.getByRole("button", { name: "Export" })).not.toBeDisabled();
+});
diff --git a/frontend/src/components/TokensList/TokensList.tsx b/frontend/src/components/TokensList/TokensList.tsx
index a8f878e..07436a5 100644
--- a/frontend/src/components/TokensList/TokensList.tsx
+++ b/frontend/src/components/TokensList/TokensList.tsx
@@ -2,10 +2,9 @@ import { useState, useEffect } from "react";
 
 import { Button, Col, Row } from "@canonical/react-components";
 
-import PaginationBar from "../base/PaginationBar";
-
 import TokensTable from "./components/TokensTable/TokensTable";
 
+import PaginationBar from "@/components/base/PaginationBar";
 import { useAppContext } from "@/context";
 import { useTokensQuery } from "@/hooks/api";
 import usePagination from "@/hooks/usePagination";
@@ -13,7 +12,7 @@ import usePagination from "@/hooks/usePagination";
 const DEFAULT_PAGE_SIZE = 50;
 
 const TokensList = () => {
-  const { setSidebar } = useAppContext();
+  const { setSidebar, rowSelection } = useAppContext();
   const [totalDataCount, setTotalDataCount] = useState(0);
   const { page, debouncedPage, size, handleNextClick, handlePreviousClick, handlePageSizeChange, setPage } =
     usePagination(DEFAULT_PAGE_SIZE, totalDataCount);
@@ -36,8 +35,10 @@ const TokensList = () => {
       <Row>
         <Col size={12}>
           <div className="u-flex u-flex--justify-end">
-            <Button>Export</Button>
-            <Button appearance="negative">Delete</Button>
+            <Button disabled={!Object.keys(rowSelection).length}>Export</Button>
+            <Button appearance="negative" disabled={!Object.keys(rowSelection).length}>
+              Delete
+            </Button>
             <Button className="p-button--positive" onClick={() => setSidebar("createToken")} type="button">
               Generate tokens
             </Button>
diff --git a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
index 6de9df3..0813ab4 100644
--- a/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
+++ b/frontend/src/components/TokensList/components/TokensTable/TokensTable.tsx
@@ -1,6 +1,6 @@
 import { useCallback, useMemo, useState } from "react";
 
-import { Input } from "@canonical/react-components";
+import { Input, Tooltip } from "@canonical/react-components";
 import type { ColumnDef, Column } from "@tanstack/react-table";
 import { flexRender, useReactTable, getCoreRowModel } from "@tanstack/react-table";
 import { format, formatDistanceStrict } from "date-fns";
@@ -8,6 +8,7 @@ import pick from "lodash/fp/pick";
 
 import type { Token } from "@/api/types";
 import CopyButton from "@/components/base/CopyButton";
+import { useAppContext } from "@/context";
 import type { useTokensQueryResult } from "@/hooks/api";
 import { copyToClipboard } from "@/utils";
 
@@ -88,7 +89,11 @@ const TokensTable = ({
         header: () => <div>Time until expiration</div>,
         cell: ({ getValue }) => {
           const { expires } = getValue();
-          return <div>{expires ? formatDistanceStrict(new Date(expires), new Date()) : null}</div>;
+          return (
+            <Tooltip message={expires ? format(new Date(expires), "yyyy-MM-dd HH:mm") : null} position="btm-center">
+              {expires ? formatDistanceStrict(new Date(expires), new Date()) : null}
+            </Tooltip>
+          );
         },
       },
       {
@@ -103,7 +108,12 @@ const TokensTable = ({
     ],
     [handleTokenCopy, isTokenCopied],
   );
-  const [rowSelection, setRowSelection] = useState({});
+  const { rowSelection, setRowSelection } = useAppContext();
+
+  // clear selection on unmount
+  useEffect(() => {
+    return () => setRowSelection({});
+  }, [setRowSelection]);
   const noItems = useMemo<Token[]>(() => [], []);
 
   const tokenTable = useReactTable<Token>({
diff --git a/frontend/src/utils/capitaliseFirst.ts b/frontend/src/utils/capitaliseFirst.ts
new file mode 100644
index 0000000..8257db9
--- /dev/null
+++ b/frontend/src/utils/capitaliseFirst.ts
@@ -0,0 +1,8 @@
+/**
+ * Capitalises the first character of a string.
+ * @param {string} - text to capitalise
+ */
+export const capitaliseFirst = (text: string): string => {
+  const [first, ...rest] = text;
+  return [first.toLocaleUpperCase(), ...rest].join("");
+};

Follow ups