sts-sponsors team mailing list archive
-
sts-sponsors team
-
Mailing list archive
-
Message #05758
[Merge] ~nickdv99/maas-site-manager:feat-side-nav-MAASENG-1413 into maas-site-manager:main
Nick De Villiers has proposed merging ~nickdv99/maas-site-manager:feat-side-nav-MAASENG-1413 into maas-site-manager:main.
Commit message:
feat: Add navigation to Site Manager UI MAASENG-1413
Requested reviews:
MAAS Committers (maas-committers)
For more details, see:
https://code.launchpad.net/~nickdv99/maas-site-manager/+git/site-manager/+merge/438521
## Done
- Added side navigation with subcomponents
- Created types for navigation items
- Added styling and collapsing functionality
- Added new icons
- Added @testing-library/user-event for integration tests
- Wrote tests for new components
## QA Steps
1. Go to /sites
2. Ensure the navigation renders
3. Click "Settings"
4. Ensure you a redirected to "/tokens" and that "Settings" is highlighted
5. Click on "Overview"
6. Ensure you are redirected to "/sites" and that "Overview" is highlighted
7. Click on "Site Manager"
8. Ensure you are redirected to "/"
9. Ensure the navigation can be pinned/unpinned
## Fixes
Fixes https://warthogs.atlassian.net/browse/MAASENG-1413
--
Your team MAAS Committers is requested to review the proposed merge of ~nickdv99/maas-site-manager:feat-side-nav-MAASENG-1413 into maas-site-manager:main.
diff --git a/frontend/index.html b/frontend/index.html
index 95ca7c0..1142633 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+ <link rel="icon" type="image/png" href="/maas-favicon-32px.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MAAS Site Manager</title>
</head>
diff --git a/frontend/package.json b/frontend/package.json
index a46f876..3870d0a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,6 +15,7 @@
"@canonical/react-components": "0.38.0",
"@tanstack/react-query": "4.24.10",
"@tanstack/react-table": "8.7.9",
+ "@testing-library/user-event": "^14.4.3",
"axios": "1.3.4",
"classnames": "2.3.2",
"date-fns": "2.29.3",
@@ -24,6 +25,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "6.8.1",
+ "react-storage-hooks": "^4.0.1",
"use-local-storage-state": "18.1.2",
"vanilla-framework": "3.11.0"
},
@@ -48,9 +50,13 @@
"eslint-plugin-no-only-tests": "3.1.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
+<<<<<<< frontend/package.json
"fishery": "2.2.2",
"i18n-iso-countries": "7.5.0",
"jsdom": "21.1.0",
+=======
+ "jsdom": "^21.1.0",
+>>>>>>> frontend/package.json
"mockdate": "3.0.5",
"msw": "1.0.1",
"npm-package-json-lint": "6.4.0",
diff --git a/frontend/public/maas-favicon-32px.png b/frontend/public/maas-favicon-32px.png
new file mode 100644
index 0000000..67cfaa6
Binary files /dev/null and b/frontend/public/maas-favicon-32px.png differ
diff --git a/frontend/src/App.scss b/frontend/src/App.scss
index 9420683..ff53c94 100644
--- a/frontend/src/App.scss
+++ b/frontend/src/App.scss
@@ -6,3 +6,7 @@
@include vf-l-application;
@import "./utils";
+@import "./patterns_icons";
+@import "./patterns_typography";
+@include maas-icons;
+@include maas-typography;
diff --git a/frontend/src/_patterns_icons.scss b/frontend/src/_patterns_icons.scss
new file mode 100644
index 0000000..5b953e0
--- /dev/null
+++ b/frontend/src/_patterns_icons.scss
@@ -0,0 +1,19 @@
+@mixin maas-icon-sidebar-collapse() {
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 16 16' width='16px' height='16px' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M 0 7.743 L 6.742 14.485 L 7.899 13.329 L 2.311 7.743 L 7.899 2.157 L 6.742 1 L 0 7.743 Z M 7.899 7.743 L 14.642 14.485 L 15.797 13.329 L 10.21 7.743 L 15.797 2.157 L 14.642 1 L 7.899 7.743 Z' fill='%23FFF' style=''/%3E%3C/svg%3E");
+}
+
+@mixin maas-icon-maas-logo() {
+ background-image: url("data:image/svg+xml,%3Csvg fill='%23fff' viewBox='0 0 165.5 174.3' width='16px' height='16px' xmlns='http://www.w3.org/2000/svg' %3E%3Cellipse cx='15.57' cy='111.46' rx='13.44' ry='13.3' /%3E%3Cpath d='M156.94 101.45H31.88a18.91 18.91 0 0 1 .27 19.55c-.09.16-.2.31-.29.46h125.08a6 6 0 0 0 6.06-5.96v-8.06a6 6 0 0 0-6-6Z' /%3E%3Cellipse cx='15.62' cy='63.98' rx='13.44' ry='13.3' /%3E%3Cpath d='M156.94 53.77H31.79a18.94 18.94 0 0 1 .42 19.75l-.16.24h124.89a6 6 0 0 0 6.06-5.94v-8.06a6 6 0 0 0-6-6Z' /%3E%3Cellipse cx='16.79' cy='16.5' rx='13.44' ry='13.3' /%3E%3Cpath d='M156.94 6.5H33.1a19.15 19.15 0 0 1 2.21 5.11A18.82 18.82 0 0 1 33.42 26l-.29.46h123.81a6 6 0 0 0 6.06-5.9V12.5a6 6 0 0 0-6-6Z' /%3E%3Cellipse cx='15.57' cy='158.94' rx='13.44' ry='13.3' /%3E%3Cpath d='M156.94 149H31.88a18.88 18.88 0 0 1 .27 19.5c-.09.16-.19.31-.29.46h125.08A6 6 0 0 0 163 163v-8.06a6 6 0 0 0-6-6Z' /%3E%3C/svg%3E");
+}
+
+@mixin maas-icons {
+ .p-icon--sidebar-toggle {
+ @extend %icon;
+ @include maas-icon-sidebar-collapse();
+ }
+
+ .p-icon--maas {
+ @extend %icon;
+ @include maas-icon-maas-logo();
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/_patterns_typography.scss b/frontend/src/_patterns_typography.scss
new file mode 100644
index 0000000..dba8f61
--- /dev/null
+++ b/frontend/src/_patterns_typography.scss
@@ -0,0 +1,13 @@
+@mixin maas-typography {
+ .p-heading--small {
+ @extend %table-header-label;
+ color: $color-dark;
+ margin-bottom: $sp-unit * 0.25;
+ text-transform: uppercase;
+ }
+ .is-dark {
+ .p-heading--small {
+ color: $color-light;
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/_settings.scss b/frontend/src/_settings.scss
index aed6d8f..1c7d703 100644
--- a/frontend/src/_settings.scss
+++ b/frontend/src/_settings.scss
@@ -5,6 +5,8 @@ $grid-max-width: math.div(1920, 16) * 1rem;
$breakpoint-x-large: 1440px;
$breakpoint-xx-large: 1920px;
+$side-navigation-z-index: 103;
+$application-layout--breakpoint-side-nav-expanded: $breakpoint-xx-large;
// TODO: uncomment and use local assets path once implemented
// https://warthogs.atlassian.net/browse/MAASENG-1448
diff --git a/frontend/src/components/MainLayout/MainLayout.tsx b/frontend/src/components/MainLayout/MainLayout.tsx
index a785bfa..a7b67db 100644
--- a/frontend/src/components/MainLayout/MainLayout.tsx
+++ b/frontend/src/components/MainLayout/MainLayout.tsx
@@ -1,8 +1,11 @@
import { Outlet } from "react-router-dom";
+
import "./MainLayout.scss";
+import Navigation from "../Navigation";
const MainLayout: React.FC = () => (
<div className="l-application">
+ <Navigation />
<main className="l-main is-maas-site-manager">
<div className="row">
<div className="col-12">
diff --git a/frontend/src/components/Navigation/Navigation.scss b/frontend/src/components/Navigation/Navigation.scss
new file mode 100644
index 0000000..3b53e1a
--- /dev/null
+++ b/frontend/src/components/Navigation/Navigation.scss
@@ -0,0 +1,128 @@
+.l-navigation {
+ @include vf-transition(
+ $property: #{width,
+ box-shadow,
+ background},
+ $duration: fast
+ );
+ &:hover,
+ &:focus-within,
+ &.is-pinned {
+ .l-navigation__controls {
+ opacity: 1;
+ visibility: visible;
+ button {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+ @media only screen and (min-width: ($breakpoint-small + 1)) {
+ transform: translateX(#{$application-layout--side-nav-width-expanded - 3rem}) translateY(0.8rem);
+ }
+ }
+ }
+ &.is-collapsed {
+ .l-navigation-collapse-toggle,
+ .l-navigation__controls {
+ i {
+ transform: rotate(180deg);
+ }
+ }
+ }
+ .l-navigation__controls {
+ margin-left: auto;
+ padding-top: 0.65rem;
+ z-index: $side-navigation-z-index + 1;
+ @include vf-transition($property: #{opacity, visibility, transform, background}, $duration: fast);
+ @media only screen and (min-width: ($breakpoint-small + 1)) {
+ opacity: 1;
+ visibility: visible;
+ position: absolute;
+ top: 0;
+ left: 0;
+ padding-top: 0;
+ transform: translateX(1rem) translateY(2.6rem);
+ }
+ @media only screen and (min-width: ($breakpoint-xx-large)) {
+ display: none;
+ }
+ }
+ .l-navigation--item-icon {
+ margin-right: $sph--small;
+ }
+}
+.p-panel.is-dark {
+ background: inherit;
+
+ .p-panel__header {
+ background-color: inherit;
+ @include vf-transition(
+ $property: #{width,
+ box-shadow,
+ background},
+ $duration: fast
+ );
+ }
+
+ .p-panel__logo {
+ color: $colors--dark-theme--text-default;
+ text-decoration: none;
+ display: flex;
+ flex-direction: column;
+ @media only screen and (max-width: ($breakpoint-small)) {
+ margin-top: 1.25rem;
+ margin-bottom: 0;
+ }
+ }
+
+ .p-panel__logo-name {
+ @extend %vf-heading-4;
+ font-size: #{map-get($font-sizes, h4)}rem;
+ line-height: map-get($line-heights, x-small);
+ margin-bottom: 1.25rem !important;
+ padding-top: 0.051rem !important;
+ margin-left: 2rem !important;
+ @media only screen and (min-width: ($breakpoint-small + 1)) {
+ margin-left: 2.5rem !important;
+ }
+ }
+
+ .p-panel__content {
+ padding-top: 0;
+ }
+ .p-side-navigation--icons {
+ & > .p-side-navigation__list:last-of-type::after {
+ content: "";
+ }
+ .p-side-navigation__list::after {
+ left: 1rem;
+ opacity: 1;
+ }
+ .p-side-navigation__footer {
+ @extend .p-side-navigation__text;
+ padding-left: 1.5rem;
+ transition-duration: 0ms;
+ }
+ }
+
+ .p-navigation__tagged-logo {
+ min-width: auto;
+ }
+ .p-navigation__tagged-logo .p-navigation__logo-tag {
+ height: 2.3rem;
+ left: 1rem;
+ @media only screen and (min-width: ($breakpoint-small + 1)) {
+ left: 1.5rem;
+ }
+ }
+
+ .p-side-navigation--icons button.p-side-navigation__button {
+ width: 100%;
+ text-align: left;
+ justify-content: flex-start;
+ }
+ .p-side-navigation--icons
+ .p-side-navigation__item
+ .p-side-navigation__item
+ .p-side-navigation__link {
+ padding-left: 4rem;
+ }
+}
diff --git a/frontend/src/components/Navigation/Navigation.test.tsx b/frontend/src/components/Navigation/Navigation.test.tsx
new file mode 100644
index 0000000..3201cb9
--- /dev/null
+++ b/frontend/src/components/Navigation/Navigation.test.tsx
@@ -0,0 +1,102 @@
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter } from "react-router-dom";
+
+import { render, screen } from "../../test-utils";
+
+import Navigation from "./Navigation";
+
+describe("Navigation", () => {
+ it("displays navigation", () => {
+ render(
+ <MemoryRouter initialEntries={[{ pathname: "/", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+ expect(screen.getByRole("navigation")).toBeInTheDocument();
+ });
+
+ it("can highlight an active URL", () => {
+ render(
+ <MemoryRouter initialEntries={[{ pathname: "/sites", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+
+ const currentMenuItem = screen.getAllByRole("link", { current: "page" })[0];
+ expect(currentMenuItem).toBeInTheDocument();
+ expect(currentMenuItem).toHaveTextContent("Overview");
+ });
+
+ it("highlights 'Overview' when active", () => {
+ render(
+ <MemoryRouter initialEntries={[{ pathname: "/sites", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+
+ const currentMenuItem = screen.getAllByRole("link", { current: "page" })[0];
+ expect(currentMenuItem).toBeInTheDocument();
+ expect(currentMenuItem).toHaveTextContent("Overview");
+ });
+
+ it("highlights 'Settings' when active", () => {
+ const { rerender } = render(
+ <MemoryRouter initialEntries={[{ pathname: "/tokens", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+
+ let currentMenuItem = screen.getAllByRole("link", { current: "page" })[0];
+ expect(currentMenuItem).toBeInTheDocument();
+ expect(currentMenuItem).toHaveTextContent("Settings");
+
+ rerender(
+ <MemoryRouter initialEntries={[{ pathname: "/requests", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+
+ currentMenuItem = screen.getAllByRole("link", { current: "page" })[0];
+ expect(currentMenuItem).toBeInTheDocument();
+ expect(currentMenuItem).toHaveTextContent("Settings");
+
+ rerender(
+ <MemoryRouter initialEntries={[{ pathname: "/users", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+
+ currentMenuItem = screen.getAllByRole("link", { current: "page" })[0];
+ expect(currentMenuItem).toBeInTheDocument();
+ expect(currentMenuItem).toHaveTextContent("Settings");
+ });
+
+ it("is collapsed by default", () => {
+ render(
+ <MemoryRouter initialEntries={[{ pathname: "/", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+ expect(screen.getByRole("navigation")).toHaveClass("is-collapsed");
+ });
+
+ it("persists collapsed state", async () => {
+ const { rerender } = render(
+ <MemoryRouter initialEntries={[{ pathname: "/", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+
+ const primaryNavigation = screen.getByRole("navigation");
+ await userEvent.click(screen.getByRole("button", { name: "expand main navigation" }));
+ expect(primaryNavigation).toHaveClass("is-pinned");
+
+ rerender(
+ <MemoryRouter initialEntries={[{ pathname: "/", key: "testKey" }]}>
+ <Navigation />
+ </MemoryRouter>,
+ );
+
+ expect(primaryNavigation).toHaveClass("is-pinned");
+ });
+});
diff --git a/frontend/src/components/Navigation/Navigation.tsx b/frontend/src/components/Navigation/Navigation.tsx
new file mode 100644
index 0000000..d55cb59
--- /dev/null
+++ b/frontend/src/components/Navigation/Navigation.tsx
@@ -0,0 +1,82 @@
+import { Button } from "@canonical/react-components";
+import classNames from "classnames";
+import { useLocation } from "react-router-dom";
+import { useStorageState } from "react-storage-hooks";
+
+import NavigationBanner from "./NavigationBanner";
+import NavigationCollapseToggle from "./NavigationCollapseToggle";
+import "./Navigation.scss";
+import NavigationItems from "./NavigationItems";
+import type { NavItem } from "./types";
+
+const navItems: NavItem[] = [
+ {
+ label: "Overview",
+ url: "/sites",
+ icon: "maas",
+ },
+];
+
+const navItemsBottom = [
+ {
+ label: "Settings",
+ url: "/tokens",
+ icon: "settings",
+ highlight: ["/tokens", "/users", "/requests"],
+ },
+];
+
+const Navigation = (): JSX.Element => {
+ const [isCollapsed, setIsCollapsed] = useStorageState<boolean>(localStorage, "appSideNavIsCollapsed", true);
+ const location = useLocation();
+ const path = location.pathname;
+
+ return (
+ <>
+ <header className="l-navigation-bar">
+ <div className="p-panel is-dark">
+ <div className="p-panel__header">
+ <NavigationBanner />
+ <div className="p-panel__controls u-nudge-down--small u-no-margin-top">
+ <Button
+ appearance="base"
+ className="has-icon is-dark"
+ onClick={() => {
+ setIsCollapsed(!isCollapsed);
+ }}
+ >
+ Menu
+ </Button>
+ </div>
+ </div>
+ </div>
+ </header>
+ <nav
+ aria-label="main navigation"
+ className={classNames("l-navigation", { "is-collapsed": isCollapsed, "is-pinned": !isCollapsed })}
+ >
+ <div className="l-navigation__drawer">
+ <div className="p-panel is-dark">
+ <div className="p-panel__header is-sticky">
+ <NavigationBanner>
+ <div className="l-navigation__controls">
+ <NavigationCollapseToggle isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed} />
+ </div>
+ </NavigationBanner>
+ </div>
+ <div className="p-panel__content">
+ <div className="p-side-navigation--icons is-dark">
+ <NavigationItems items={navItems} path={path} />
+ </div>
+ <div className="p-side-navigation--icons is-dark">
+ <NavigationItems items={navItemsBottom} path={path} />
+ </div>
+ </div>
+ </div>
+ </div>
+ </nav>
+ </>
+ );
+};
+
+export default Navigation;
diff --git a/frontend/src/components/Navigation/NavigationBanner/NavigationBanner.test.tsx b/frontend/src/components/Navigation/NavigationBanner/NavigationBanner.test.tsx
new file mode 100644
index 0000000..125be52
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationBanner/NavigationBanner.test.tsx
@@ -0,0 +1,17 @@
+import { BrowserRouter } from "react-router-dom";
+
+import { screen, render } from "../../../test-utils";
+
+import NavigationBanner from "./NavigationBanner";
+
+describe("Navigation Banner", () => {
+ it("displays a link to the homepage", () => {
+ render(
+ <BrowserRouter>
+ <NavigationBanner />
+ </BrowserRouter>,
+ );
+
+ expect(screen.getByRole("link", { name: /Homepage/ })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/Navigation/NavigationBanner/NavigationBanner.tsx b/frontend/src/components/Navigation/NavigationBanner/NavigationBanner.tsx
new file mode 100644
index 0000000..f7a833d
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationBanner/NavigationBanner.tsx
@@ -0,0 +1,42 @@
+import { Link, useLocation } from "react-router-dom";
+
+import { isSelected } from "../utils";
+
+const NavigationBanner = ({ children }: { children?: React.ReactNode }): JSX.Element => {
+ const location = useLocation();
+ const homepageLink = { url: "/", label: "Homepage" };
+ return (
+ <>
+ <Link
+ aria-current={isSelected(location.pathname, homepageLink)}
+ aria-label={homepageLink.label}
+ className="p-panel__logo"
+ to={homepageLink.url}
+ >
+ <div className="p-navigation__tagged-logo">
+ <div className="p-navigation__logo-tag">
+ <svg
+ className="p-panel__logo-icon p-navigation__logo-icon"
+ fill="#fff"
+ viewBox="0 0 165.5 174.3"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <ellipse cx="15.57" cy="111.46" rx="13.44" ry="13.3" />
+ <path d="M156.94 101.45H31.88a18.91 18.91 0 0 1 .27 19.55c-.09.16-.2.31-.29.46h125.08a6 6 0 0 0 6.06-5.96v-8.06a6 6 0 0 0-6-6Z" />
+ <ellipse cx="15.62" cy="63.98" rx="13.44" ry="13.3" />
+ <path d="M156.94 53.77H31.79a18.94 18.94 0 0 1 .42 19.75l-.16.24h124.89a6 6 0 0 0 6.06-5.94v-8.06a6 6 0 0 0-6-6Z" />
+ <ellipse cx="16.79" cy="16.5" rx="13.44" ry="13.3" />
+ <path d="M156.94 6.5H33.1a19.15 19.15 0 0 1 2.21 5.11A18.82 18.82 0 0 1 33.42 26l-.29.46h123.81a6 6 0 0 0 6.06-5.9V12.5a6 6 0 0 0-6-6Z" />
+ <ellipse cx="15.57" cy="158.94" rx="13.44" ry="13.3" />
+ <path d="M156.94 149H31.88a18.88 18.88 0 0 1 .27 19.5c-.09.16-.19.31-.29.46h125.08A6 6 0 0 0 163 163v-8.06a6 6 0 0 0-6-6Z" />
+ </svg>
+ </div>
+ <div className="p-panel__logo-name is-fading-when-collapsed ">Site Manager</div>
+ </div>
+ </Link>
+ {children}
+ </>
+ );
+};
+
+export default NavigationBanner;
diff --git a/frontend/src/components/Navigation/NavigationBanner/index.ts b/frontend/src/components/Navigation/NavigationBanner/index.ts
new file mode 100644
index 0000000..f31c316
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationBanner/index.ts
@@ -0,0 +1 @@
+export { default } from "./NavigationBanner";
diff --git a/frontend/src/components/Navigation/NavigationCollapseToggle/NavigationCollapseToggle.tsx b/frontend/src/components/Navigation/NavigationCollapseToggle/NavigationCollapseToggle.tsx
new file mode 100644
index 0000000..96a1b8d
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationCollapseToggle/NavigationCollapseToggle.tsx
@@ -0,0 +1,33 @@
+import { Button, Icon, Tooltip } from "@canonical/react-components";
+import classNames from "classnames";
+
+const NavigationCollapseToggle = ({
+ isCollapsed,
+ setIsCollapsed,
+ className,
+}: {
+ isCollapsed: boolean;
+ setIsCollapsed: (isCollapsed: boolean) => void;
+ className?: string;
+}): JSX.Element => {
+ return (
+ <Tooltip message={<>{!isCollapsed ? "collapse" : "expand"}</>} position="right">
+ <Button
+ appearance="base"
+ aria-label={`${!isCollapsed ? "collapse" : "expand"} main navigation`}
+ className={classNames("is-dense has-icon is-dark u-no-margin l-navigation-collapse-toggle", className)}
+ onClick={(e) => {
+ setIsCollapsed(!isCollapsed);
+ // Make sure the button does not have focus
+ // .l-navigation remains open with :focus-within
+ e.stopPropagation();
+ e.currentTarget.blur();
+ }}
+ >
+ <Icon light name="sidebar-toggle" />
+ </Button>
+ </Tooltip>
+ );
+};
+
+export default NavigationCollapseToggle;
diff --git a/frontend/src/components/Navigation/NavigationCollapseToggle/index.ts b/frontend/src/components/Navigation/NavigationCollapseToggle/index.ts
new file mode 100644
index 0000000..c398438
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationCollapseToggle/index.ts
@@ -0,0 +1 @@
+export { default } from "./NavigationCollapseToggle";
diff --git a/frontend/src/components/Navigation/NavigationItem/NavigationItem.tsx b/frontend/src/components/Navigation/NavigationItem/NavigationItem.tsx
new file mode 100644
index 0000000..242df50
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationItem/NavigationItem.tsx
@@ -0,0 +1,40 @@
+import { useId } from "react";
+
+import { Icon } from "@canonical/react-components";
+import { Link } from "react-router-dom";
+
+import type { NavLink } from "../types";
+import { isSelected } from "../utils";
+
+type Props = {
+ navLink: NavLink;
+ path: string;
+};
+
+const NavigationItem = ({ navLink, path }: Props): JSX.Element => {
+ const id = useId();
+ return (
+ <li
+ aria-labelledby={`${navLink.label}-${id}`}
+ className={`p-side-navigation__item${isSelected(path, navLink) ? " is-selected" : ""}`}
+ >
+ <Link
+ aria-current={isSelected(path, navLink) ? "page" : undefined}
+ className="p-side-navigation__link"
+ id={`${navLink.label}-${id}`}
+ to={navLink.url}
+ >
+ {navLink.icon ? (
+ typeof navLink.icon === "string" ? (
+ <Icon className="p-side-navigation__icon" light name={navLink.icon} />
+ ) : (
+ <>{navLink.icon}</>
+ )
+ ) : null}
+ <span className="p-side-navigation__label">{navLink.label}</span>
+ </Link>
+ </li>
+ );
+};
+
+export default NavigationItem;
diff --git a/frontend/src/components/Navigation/NavigationItem/index.ts b/frontend/src/components/Navigation/NavigationItem/index.ts
new file mode 100644
index 0000000..dba7aec
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationItem/index.ts
@@ -0,0 +1 @@
+export { default } from "./NavigationItem";
diff --git a/frontend/src/components/Navigation/NavigationItems/NavigationItems.tsx b/frontend/src/components/Navigation/NavigationItems/NavigationItems.tsx
new file mode 100644
index 0000000..b077840
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationItems/NavigationItems.tsx
@@ -0,0 +1,66 @@
+import { useId, useMemo } from "react";
+
+import { Icon } from "@canonical/react-components";
+import classNames from "classnames";
+
+import NavigationItem from "../NavigationItem/NavigationItem";
+import type { NavGroup, NavItem } from "../types";
+import { isNavGroup, isSelected } from "../utils";
+
+type Props = {
+ items: NavItem[];
+ logout?: () => void;
+ path: string;
+};
+
+const NavigationItemGroup = ({ group, path }: { group: NavGroup } & Pick<Props, "path">) => {
+ const id = useId();
+ const hasActiveChild = useMemo(() => {
+ for (const navLink of group.navLinks) {
+ if (isSelected(path, navLink)) {
+ return true;
+ }
+ }
+ return false;
+ }, [group, path]);
+
+ return (
+ <>
+ <li className={classNames("p-side-navigation__item", { "has-active-child": hasActiveChild })}>
+ <span className="p-side-navigation__text" key={`${group.groupTitle}-${id}`}>
+ {group.groupIcon ? (
+ typeof group.groupIcon === "string" ? (
+ <Icon className="p-side-navigation__icon" light name={group.groupIcon} />
+ ) : (
+ <>{group.groupIcon}</>
+ )
+ ) : null}
+ <div className="p-side-navigation__label p-heading--small" id={`${group.groupTitle}-${id}`}>
+ {group.groupTitle}
+ </div>
+ </span>
+ <ul aria-labelledby={`${group.groupTitle}-${id}`} className="p-side-navigation__list">
+ {group.navLinks.map((navLink, i) => (
+ <NavigationItem key={i} navLink={navLink} path={path} />
+ ))}
+ </ul>
+ </li>
+ </>
+ );
+};
+
+const NavigationItems = ({ items, logout, path }: Props): JSX.Element => {
+ return (
+ <>
+ <ul className="p-side-navigation__list">
+ {items.map((item, i) => {
+ if (isNavGroup(item)) {
+ return <NavigationItemGroup group={item} key={`${i}-${item.groupTitle}`} path={path} />;
+ } else return <NavigationItem key={i} navLink={item} path={path} />;
+ })}
+ </ul>
+ </>
+ );
+};
+
+export default NavigationItems;
diff --git a/frontend/src/components/Navigation/NavigationItems/index.ts b/frontend/src/components/Navigation/NavigationItems/index.ts
new file mode 100644
index 0000000..1f708a3
--- /dev/null
+++ b/frontend/src/components/Navigation/NavigationItems/index.ts
@@ -0,0 +1 @@
+export { default } from "./NavigationItems";
diff --git a/frontend/src/components/Navigation/index.ts b/frontend/src/components/Navigation/index.ts
new file mode 100644
index 0000000..f8785c3
--- /dev/null
+++ b/frontend/src/components/Navigation/index.ts
@@ -0,0 +1 @@
+export { default } from "./Navigation";
diff --git a/frontend/src/components/Navigation/types.ts b/frontend/src/components/Navigation/types.ts
new file mode 100644
index 0000000..61f2925
--- /dev/null
+++ b/frontend/src/components/Navigation/types.ts
@@ -0,0 +1,15 @@
+export type NavLink = {
+ adminOnly?: boolean;
+ highlight?: string | string[];
+ icon?: string | React.ReactNode;
+ label: string;
+ url: string;
+};
+
+export type NavGroup = {
+ navLinks: NavLink[];
+ groupTitle?: string;
+ groupIcon?: string | React.ReactNode;
+};
+
+export type NavItem = NavGroup | NavLink;
diff --git a/frontend/src/components/Navigation/utils.ts b/frontend/src/components/Navigation/utils.ts
new file mode 100644
index 0000000..a32b44f
--- /dev/null
+++ b/frontend/src/components/Navigation/utils.ts
@@ -0,0 +1,21 @@
+import { matchPath } from "react-router-dom";
+
+import type { NavLink, NavItem, NavGroup } from "./types";
+
+export const isSelected = (path: string, link: NavLink): boolean => {
+ // Use the provided highlight(s) or just use the url.
+ let highlights = link.highlight || link.url;
+ // If the provided highlights aren't an array then make them one so that we
+ // can loop over them.
+ if (!Array.isArray(highlights)) {
+ highlights = [highlights];
+ }
+ // Check if one of the highlight urls matches the current path.
+ return highlights.some((highlight) =>
+ // Check the full path, for both legacy/new clients as sometimes the lists
+ // are in one client and the details in the other.
+ matchPath({ path: highlight, end: false }, path),
+ );
+};
+
+export const isNavGroup = (item: NavItem): item is NavGroup => "navLinks" in item;
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 6b20352..8f13e98 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -1511,6 +1511,14 @@
"@testing-library/dom" "^8.5.0"
"@types/react-dom" "^18.0.0"
+<<<<<<< frontend/yarn.lock
+=======
+"@testing-library/user-event@^14.4.3":
+ version "14.4.3"
+ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591"
+ integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==
+
+>>>>>>> frontend/yarn.lock
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@@ -1924,7 +1932,11 @@ acorn-walk@^8.0.2, acorn-walk@^8.2.0:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
+<<<<<<< frontend/yarn.lock
acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.0, acorn@^8.8.1, acorn@^8.8.2:
+=======
+acorn@^8.1.0, acorn@^8.8.0, acorn@^8.8.1, acorn@^8.8.2:
+>>>>>>> frontend/yarn.lock
version "8.8.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
@@ -3638,6 +3650,7 @@ https-proxy-agent@^5.0.1:
agent-base "6"
debug "4"
+<<<<<<< frontend/yarn.lock
i18n-iso-countries@7.5.0:
version "7.5.0"
resolved "https://registry.yarnpkg.com/i18n-iso-countries/-/i18n-iso-countries-7.5.0.tgz#74fedd72619526a195cfb2e768fe1d82eed2123f"
@@ -3645,6 +3658,8 @@ i18n-iso-countries@7.5.0:
dependencies:
diacritics "1.3.0"
+=======
+>>>>>>> frontend/yarn.lock
iconv-lite@0.6.3:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
@@ -3886,6 +3901,7 @@ is-plain-obj@^3.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7"
integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==
+<<<<<<< frontend/yarn.lock
is-plain-object@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@@ -3893,6 +3909,8 @@ is-plain-object@^2.0.4:
dependencies:
isobject "^3.0.1"
+=======
+>>>>>>> frontend/yarn.lock
is-potential-custom-element-name@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
@@ -4087,7 +4105,11 @@ js-yaml@^4.1.0:
dependencies:
argparse "^2.0.1"
+<<<<<<< frontend/yarn.lock
jsdom@21.1.0:
+=======
+jsdom@^21.1.0:
+>>>>>>> frontend/yarn.lock
version "21.1.0"
resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.0.tgz#d56ba4a84ed478260d83bd53dc181775f2d8e6ef"
integrity sha512-m0lzlP7qOtthD918nenK3hdItSd2I+V3W9IrBcB36sqDwG+KnUs66IF5GY7laGWUnlM9vTsD0W1QwSEBYWWcJg==
@@ -4525,11 +4547,14 @@ nwsapi@^2.2.2:
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0"
integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==
+<<<<<<< frontend/yarn.lock
o-stream@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/o-stream/-/o-stream-0.3.0.tgz#204d27bc3fb395164507d79b381e91752e8daedc"
integrity sha512-gbzl6qCJZ609x/M2t25HqCYQagFzWYCtQ84jcuObGr+V8D1Am4EVubkF4J+XFs6ukfiv96vNeiBb8FrbbMZYiQ==
+=======
+>>>>>>> frontend/yarn.lock
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@@ -4996,6 +5021,11 @@ react-router@6.8.1:
dependencies:
"@remix-run/router" "1.3.2"
+react-storage-hooks@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/react-storage-hooks/-/react-storage-hooks-4.0.1.tgz#e30ed5cda48c77c431ecc02ec3824bd615f5b7fb"
+ integrity sha512-fetDkT5RDHGruc2NrdD1iqqoLuXgbx6AUpQSQLLkrCiJf8i97EtwJNXNTy3+GRfsATLG8TZgNc9lGRZOaU5yQA==
+
react-table@7.8.0:
version "7.8.0"
resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.8.0.tgz#07858c01c1718c09f7f1aed7034fcfd7bda907d2"
@@ -5364,11 +5394,14 @@ source-map-support@^0.5.21, source-map-support@~0.5.20:
buffer-from "^1.0.0"
source-map "^0.6.0"
+<<<<<<< frontend/yarn.lock
source-map@^0.5.1:
version "0.5.7"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==
+=======
+>>>>>>> frontend/yarn.lock
source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
@@ -5567,6 +5600,7 @@ symbol-tree@^3.2.4:
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
+<<<<<<< frontend/yarn.lock
terser@^5.7.1:
version "5.16.5"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.16.5.tgz#1c285ca0655f467f92af1bbab46ab72d1cb08e5a"
@@ -5577,6 +5611,8 @@ terser@^5.7.1:
commander "^2.20.0"
source-map-support "~0.5.20"
+=======
+>>>>>>> frontend/yarn.lock
text-table@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
@@ -5791,11 +5827,14 @@ unicode-property-aliases-ecmascript@^2.0.0:
resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd"
integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==
+<<<<<<< frontend/yarn.lock
unique-names-generator@4.7.1:
version "4.7.1"
resolved "https://registry.yarnpkg.com/unique-names-generator/-/unique-names-generator-4.7.1.tgz#966407b12ba97f618928f77322cfac8c80df5597"
integrity sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==
+=======
+>>>>>>> frontend/yarn.lock
universalify@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
Follow ups