← Back to team overview

sts-sponsors team mailing list archive

[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