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