Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
251 changes: 251 additions & 0 deletions src/components/navigation/SidebarNav.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import {
Abc,
ArrowForward,
CorporateFare,
GraphicEq,
Insights,
Menu,
Schedule,
} from "@mui/icons-material";
import { SidebarNav } from "./SidebarNav";
import { Meta, StoryObj } from "@storybook/react";
import React from "react";
import {
AppBar,
Box,
Divider,
IconButton,
Toolbar,
Typography,
} from "../MUI/MuiWrapped";
import { Theme } from "@mui/material/styles";
import { Logo } from "../controls/Logo";
import { ColourSchemeButton } from "../controls/ColourSchemeButton";
import { NavLink, MemoryRouter } from "react-router-dom";

const meta: Meta<typeof SidebarNav> = {
title: "Components/Navigation/SidebarNav",
component: SidebarNav,
decorators: [
(Story) => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
tags: ["autodocs"],
parameters: {
docs: {
pages: {},
description: {
component: `A collapsing/expanding sidebar for your app's primary navigation. Click on the individual stories to see the examples.`,
},
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

const standardLinks = [
{
navItems: [
{
label: "Setup",
icon: <Abc />,
linkProps: { href: "" },
},
{
label: "Acquisition",
icon: <ArrowForward />,
linkProps: { href: "" },
selected: true,
},
{
label: "Analysis",
icon: <GraphicEq />,
linkProps: { href: "" },
},
],
},
];

export const NormalLinks: Story = {
args: {
navigation: standardLinks,
open: true,
},
parameters: {
docs: {
description: {
story:
"When using standard links, the caller must handle the selected state and set it to the correct item.",
},
},
},
};

const reactRouterNavigation = [
{
navItems: [
{
label: "Setup",
icon: <Abc />,
linkProps: { to: "/1", component: NavLink },
},
{
label: "Acquisition",
icon: <ArrowForward />,
linkProps: { to: "/2", component: NavLink },
},
{
label: "Analysis",
icon: <GraphicEq />,
linkProps: { to: "/3", component: NavLink },
},
],
},
{
navItems: [
{
label: "Organisation",
icon: <CorporateFare />,
linkProps: { to: "/4", component: NavLink },
},
],
},
];

export const RouterLinks: Story = {
args: {
navigation: reactRouterNavigation,
open: false,
},
parameters: {
docs: {
description: {
story: `React Router _NavLinks_ will handle selected state internally.`,
},
},
},
};

const groupedNavigation = [
{
navItems: [
{
label: "Setup",
icon: <Abc />,
linkProps: { to: "/1", component: NavLink },
},
{
label: "Acquisition",
icon: <ArrowForward />,
linkProps: { to: "/2", component: NavLink },
},
],
},
{
navItems: [
{
label: "Analysis",
icon: <GraphicEq />,
linkProps: { to: "/3", component: NavLink },
},
{
label: "Data Browse",
icon: <Insights />,
linkProps: { to: "/4", component: NavLink },
},
],
},
{
navItems: [
{
label: "Log",
icon: <Schedule />,
linkProps: { to: "/5", component: NavLink },
},
],
},
];

export const GroupedNavigation: Story = {
args: {
navigation: groupedNavigation,
open: true,
},
parameters: {
docs: {
description: {
story: "Sections are grouped with dividers",
},
},
},
};

export const WithAppBar: Story = {
render: (_args) => {
const [open, setOpen] = React.useState(true);
return (
<Box sx={{ display: "flex" }}>
<AppBar
position="fixed"
color="inherit"
sx={{
zIndex: (theme: Theme) => theme.zIndex.drawer + 1,
borderBottom: "1px solid",
borderColor: "divider",
}}
elevation={0}
>
<Toolbar>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={() => setOpen(!open)}
>
<Menu />
</IconButton>

<Box sx={{ mr: 2, mt: 1.5 }}>
<Logo sx={{ display: "block" }} />
</Box>

<Divider orientation="vertical" variant="middle" flexItem />

<Typography
variant="h6"
noWrap
component="div"
sx={{
ml: 1.5,
mt: 1.25,
mr: 1.25,
}}
>
My app
</Typography>

<Box sx={{ ml: "auto" }}>
<ColourSchemeButton />
</Box>
</Toolbar>
</AppBar>

<SidebarNav navigation={reactRouterNavigation} open={open} />
</Box>
);
},
parameters: {
docs: {
description: {
story:
"MUI wants to draw a Drawer above everything, so in this example the AppBar's zIndex is increased.",
},
},
},
};
121 changes: 121 additions & 0 deletions src/components/navigation/SidebarNav.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { render, screen } from "@testing-library/react";
import { Navigation, SidebarNav } from "./SidebarNav";
import { createMemoryRouter, NavLink, RouterProvider } from "react-router-dom";
import userEvent from "@testing-library/user-event";

describe("SidebarNav", () => {
const navigation: Navigation = [
{
navItems: [
{
label: "Setup",
icon: <div data-testid="navicon1" />,
linkProps: { component: NavLink, to: "/setup" },
},
{
label: "Acquisition",
icon: <div data-testid="navicon2" />,
linkProps: { component: NavLink, to: "/acq" },
},
{
label: "Analysis",
icon: <div data-testid="navicon3" />,
linkProps: { component: NavLink, to: "/analysis" },
},
],
},
{
navItems: [
{
label: "Organisation",
icon: <div data-testid="navicon4" />,
linkProps: { href: "https://www.example.com" },
},
],
},
];

function renderSidenav(open: boolean) {
const router = createMemoryRouter([
{
path: "/",
element: <SidebarNav navigation={navigation} open={open} />,
},
]);
render(<RouterProvider router={router} />);
}

it("Shows icons and names when open", () => {
renderSidenav(true);

const items = navigation[0].navItems;

items.forEach((item) => {
const button = screen.getByRole("link", { name: item.label });
expect(button).toBeVisible();
const label = screen.getByText(item.label);
expect(label).toBeVisible();
});
["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) =>
expect(screen.getByTestId(id)).toBeVisible(),
);
});

it("Shows icons only when closed", () => {
renderSidenav(false);
const items = navigation[0].navItems;
items.forEach((item) => {
const button = screen.getByRole("link", { name: item.label });
expect(button).toBeVisible(); // a11y-wise still visible
const label = screen.getByText(item.label);
expect(label).toBeInTheDocument(); // label exists but
expect(label).not.toBeVisible(); // not visible
});
["navicon1", "navicon2", "navicon3", "navicon4"].forEach((id) =>
expect(screen.getByTestId(id)).toBeVisible(),
);
});

it("shows tooltip on buttons when closed", async () => {
renderSidenav(false);

const icon = screen.getByTestId("navicon2");
const user = userEvent.setup();
await user.hover(icon);

// notice we await because the tooltip appears after some time
const tooltip = await screen.findByRole("tooltip", { name: "Acquisition" });
expect(tooltip).toBeVisible();
});

it("shows no tooltip on buttons when open", async () => {
renderSidenav(true);

const icon = screen.getByTestId("navicon2");
const user = userEvent.setup();
await user.hover(icon);

const tooltip = screen.queryByRole("tooltip", {
name: "Acquisition",
});
expect(tooltip).not.toBeInTheDocument();
});

it("creates divider between nav sections", () => {
renderSidenav(true);
const divider = screen.queryByRole("separator");
expect(divider).toBeInTheDocument();
});

it("renders internal and external links with correct href", () => {
// even though specified differently, ultimately both types
// should have the correct href attribute
renderSidenav(true);

const externalLink = screen.getByRole("link", { name: "Organisation" });
expect(externalLink).toHaveAttribute("href", "https://www.example.com");

const internalLink = screen.getByRole("link", { name: "Setup" });
expect(internalLink).toHaveAttribute("href", "/setup");
});
});
Loading
Loading