Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow side nav bar to take children elements #3099

Merged
merged 8 commits into from
Apr 20, 2023
Merged
Show file tree
Hide file tree
Changes from 5 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
7 changes: 7 additions & 0 deletions .github/workflows/frontend_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ jobs:
name: cypress-videos
path: /home/runner/work/fides/fides/clients/admin-ui/cypress/videos/*.mp4

- name: Cypress component tests
uses: cypress-io/github-action@v4
with:
working-directory: clients/admin-ui
install: false
component: true

Privacy-Center-Unit:
runs-on: ubuntu-latest
strategy:
Expand Down
126 changes: 126 additions & 0 deletions clients/admin-ui/cypress/components/NavSideBar.cy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as React from "react";

import {
configureNavGroups,
findActiveNav,
NavConfigGroup,
} from "~/features/common/nav/v2/nav-config";
import { UnconnectedNavSideBar } from "~/features/common/nav/v2/NavSideBar";

const ACTIVE_COLOR = "rgb(130, 78, 242)";
const INACTIVE_COLOR = "rgb(45, 55, 72)";

const selectLinkColor = (title: string) =>
cy.contains("a", title).should("have.css", "color");

const verifyActiveState = ({
active,
inactive,
}: {
active: string[];
inactive: string[];
}) => {
active.forEach((title) => {
selectLinkColor(title).should("eql", ACTIVE_COLOR);
});
inactive.forEach((title) => {
selectLinkColor(title).should("eql", INACTIVE_COLOR);
});
};

describe("NavSideBar", () => {
it("renders children nav links", () => {
const config: NavConfigGroup[] = [
{
title: "Privacy requests",
routes: [
{
path: "/privacy-requests",
scopes: [],
},
{
title: "Consent",
path: "/consent",
scopes: [],
routes: [
{
title: "Privacy notices",
path: "/consent/privacy-notices",
scopes: [],
routes: [
{
title: "3rd level page",
path: "/consent/privacy-notices/third-level-page",
scopes: [],
},
],
},
{
title: "Cookies",
path: "/consent/cookies",
scopes: [],
},
],
},
],
},
];
const navGroups = configureNavGroups({
config,
userScopes: [],
});
const path = "/consent/privacy-notices";
const activeNav = findActiveNav({ navGroups, path });

// First check if the active path is /consent/privacy-notices
cy.mount(
<UnconnectedNavSideBar
routerPathname={path}
groups={navGroups}
active={activeNav}
/>
);

cy.contains("nav li", "Privacy requests");
cy.contains("nav li", "Consent").within(() => {
cy.contains("a", "Privacy notices");
cy.contains("a", "Cookies");
});

verifyActiveState({
active: ["Privacy notices", "Consent"],
inactive: ["Privacy requests", "3rd level page", "Cookies"],
});

// Check if the active path is only on the first level
cy.mount(
<UnconnectedNavSideBar
routerPathname="/consent"
groups={navGroups}
active={activeNav}
/>
);
verifyActiveState({
active: ["Consent"],
inactive: [
"Privacy notices",
"Privacy requests",
"3rd level page",
"Cookies",
],
});

// Check if the active path is deeper nested
cy.mount(
<UnconnectedNavSideBar
routerPathname="/consent/privacy-notices/third-level-page"
groups={navGroups}
active={activeNav}
/>
);
verifyActiveState({
active: ["Consent", "Privacy notices", "3rd level page"],
inactive: ["Privacy requests", "Cookies"],
});
});
});
67 changes: 54 additions & 13 deletions clients/admin-ui/src/features/common/nav/v2/NavSideBar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,51 @@
import { NavList } from "@fidesui/components";
import { Heading, VStack } from "@fidesui/react";
import { Heading, UnorderedList, VStack } from "@fidesui/react";
import { useRouter } from "next/router";
import React from "react";

import { useNav } from "./hooks";
import type { ActiveNav, NavGroup, NavGroupChild } from "./nav-config";
import { NavSideBarLink } from "./NavLink";

export const NavSideBar = () => {
const router = useRouter();
const nav = useNav({ path: router.pathname });
const NavListItem = ({
title,
path,
children,
routerPathname,
}: NavGroupChild & { routerPathname: string }) => {
const isActive = routerPathname.startsWith(path);

return (
<>
<NavSideBarLink href={path} isActive={isActive}>
{title}
</NavSideBarLink>
{children.length ? (
<UnorderedList>
{children.map((childRoute) => (
<NavListItem
key={childRoute.title}
routerPathname={routerPathname}
{...childRoute}
/>
))}
</UnorderedList>
) : null}
</>
);
};

/**
* Similar to NavSideBar, but without hooks so that it is easier to test
*/
export const UnconnectedNavSideBar = ({
routerPathname,
...nav
}: {
groups: NavGroup[];
active: ActiveNav | undefined;
routerPathname: string;
}) => {
// Don't render the sidebar if no group is active
if (!nav.active) {
return null;
Expand All @@ -19,16 +55,21 @@ export const NavSideBar = () => {
<VStack as="nav" align="left" spacing={4} width="200px">
<Heading size="md">{nav.active.title}</Heading>
<NavList>
{nav.active.children.map(({ title, path }) => {
const isActive = router.pathname.startsWith(path);

return (
<NavSideBarLink key={title} href={path} isActive={isActive}>
{title}
</NavSideBarLink>
);
})}
{nav.active.children.map((childRoute) => (
<NavListItem
key={childRoute.title}
routerPathname={routerPathname}
{...childRoute}
/>
))}
</NavList>
</VStack>
);
};

export const NavSideBar = () => {
const router = useRouter();
const nav = useNav({ path: router.pathname });

return <UnconnectedNavSideBar routerPathname={router.pathname} {...nav} />;
};
114 changes: 85 additions & 29 deletions clients/admin-ui/src/features/common/nav/v2/nav-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export type NavConfigRoute = {
requiresFlag?: FlagNames;
/** This route is only available if the user has ANY of these scopes */
scopes: ScopeRegistryEnum[];
/** Child routes which will be rendered in the side nav */
routes?: NavConfigRoute[];
};

export type NavConfigGroup = {
Expand Down Expand Up @@ -50,11 +52,21 @@ export const NAV_CONFIG: NavConfigGroup[] = [
scopes: [ScopeRegistryEnum.MESSAGING_CREATE_OR_UPDATE],
},
{
title: "Privacy notices",
title: "Consent",
// For now, we don't have a full Consent page, so just use the privacy notice route
path: routes.PRIVACY_NOTICES_ROUTE,
requiresFlag: "privacyNotices",
requiresPlus: true,
scopes: [ScopeRegistryEnum.PRIVACY_NOTICE_READ],
routes: [
{
title: "Privacy notices",
path: routes.PRIVACY_NOTICES_ROUTE,
requiresFlag: "privacyNotices",
requiresPlus: true,
scopes: [ScopeRegistryEnum.PRIVACY_NOTICE_READ],
},
],
},
],
},
Expand Down Expand Up @@ -147,6 +159,7 @@ export type NavGroupChild = {
title: string;
path: string;
exact?: boolean;
children: Array<NavGroupChild>;
};

export type NavGroup = {
Expand Down Expand Up @@ -203,17 +216,73 @@ const navRouteInScope = (
return true;
};

interface ConfigureNavProps {
config: NavConfigGroup[];
userScopes: ScopeRegistryEnum[];
hasPlus?: boolean;
flags?: Record<string, boolean>;
}

const configureNavRoute = ({
route,
hasPlus,
flags,
userScopes,
navGroupTitle,
}: Omit<ConfigureNavProps, "config"> & {
route: NavConfigRoute;
navGroupTitle: string;
}): NavGroupChild | undefined => {
// If the target route would require plus in a non-plus environment,
// exclude it from the group.
if (route.requiresPlus && !hasPlus) {
return undefined;
}

// If the target route is protected by a feature flag that is not enabled,
// exclude it from the group
if (route.requiresFlag && (!flags || !flags[route.requiresFlag])) {
return undefined;
}

// If the target route is protected by a scope that the user does not
// have, exclude it from the group
if (!navRouteInScope(route, userScopes)) {
return undefined;
}

const children: NavGroupChild["children"] = [];
if (route.routes) {
route.routes.forEach((childRoute) => {
const configuredChildRoute = configureNavRoute({
route: childRoute,
userScopes,
hasPlus,
flags,
navGroupTitle,
});
if (configuredChildRoute) {
children.push(configuredChildRoute);
}
});
}

const groupChild: NavGroupChild = {
title: route.title ?? navGroupTitle,
path: route.path,
exact: route.exact,
children,
};

return groupChild;
};

export const configureNavGroups = ({
config,
userScopes,
hasPlus = false,
flags,
}: {
config: NavConfigGroup[];
userScopes: ScopeRegistryEnum[];
hasPlus?: boolean;
flags?: Record<string, boolean>;
}): NavGroup[] => {
}: ConfigureNavProps): NavGroup[] => {
const navGroups: NavGroup[] = [];

config.forEach((group) => {
Expand All @@ -228,29 +297,16 @@ export const configureNavGroups = ({
navGroups.push(navGroup);

group.routes.forEach((route) => {
// If the target route would require plus in a non-plus environment,
// exclude it from the group.
if (route.requiresPlus && !hasPlus) {
return;
}

// If the target route is protected by a feature flag that is not enabled,
// exclude it from the group
if (route.requiresFlag && (!flags || !flags[route.requiresFlag])) {
return;
}

// If the target route is protected by a scope that the user does not
// have, exclude it from the group
if (!navRouteInScope(route, userScopes)) {
return;
}

navGroup.children.push({
title: route.title ?? navGroup.title,
path: route.path,
exact: route.exact,
const routeConfig = configureNavRoute({
route,
hasPlus,
flags,
userScopes,
navGroupTitle: group.title,
});
if (routeConfig) {
navGroup.children.push(routeConfig);
}
});
});

Expand Down
6 changes: 1 addition & 5 deletions clients/admin-ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,5 @@
"**/*.tsx",
"**/*.d.ts"
],
"exclude": [
"node_modules",
"cypress/**/*.ts",
"../../admin-ui/cypress.config.ts"
allisonking marked this conversation as resolved.
Show resolved Hide resolved
]
"exclude": ["node_modules", "cypress/**/*.ts", "cypress/**/*.tsx"]
}