diff --git a/next/site-config/anvil-portal/prod/config.ts b/next/site-config/anvil-portal/prod/config.ts index 1b3a3697c..1a4568a1a 100644 --- a/next/site-config/anvil-portal/prod/config.ts +++ b/next/site-config/anvil-portal/prod/config.ts @@ -48,7 +48,7 @@ const config: SiteConfig = { label: "Explorer", }), target: ANCHOR_TARGET.BLANK, - url: `${EXPLORER_URL}/datasets`, + url: `${EXPLORER_URL}/explore/datasets`, }, ], url: "", diff --git a/src/components/header/common/entities.ts b/src/components/header/common/entities.ts index b77156398..bff346630 100644 --- a/src/components/header/common/entities.ts +++ b/src/components/header/common/entities.ts @@ -1,3 +1,5 @@ +import { ReactNode } from "react"; +import { Target } from "../../target/target.model"; import { Social } from "../components/socials/socials"; /** @@ -27,6 +29,13 @@ export interface Logo { * Model of nav link item to be use as props for the Header and Footer component. */ export interface NavLinkItem { - label: string; + featureFlag?: boolean; + label: ReactNode; + menuItems?: MenuItem[]; + target?: Target; url: string; } + +export interface MenuItem extends NavLinkItem { + description?: string; +} diff --git a/src/components/header/common/utils.ts b/src/components/header/common/utils.ts new file mode 100644 index 000000000..8fb774df9 --- /dev/null +++ b/src/components/header/common/utils.ts @@ -0,0 +1,43 @@ +import { Header as HeaderProps, NavLinkItem } from "./entities"; + +/** + * Returns the header navigation links for the site config and feature flag. + * @param navLinks - Nav links. + * @param isFeatureFlag - Flag indicating if feature is available to user. + * @returns navigation links. + */ +function filterFeatureFlagNavigation( + navLinks: NavLinkItem[], + isFeatureFlag: boolean +): NavLinkItem[] { + return navLinks.filter( + ({ featureFlag }) => + featureFlag === undefined || featureFlag === isFeatureFlag + ); +} + +/** + * Returns the header properties for the site config and feature flag. + * @param header - Site config header. + * @param isFeatureFlag - Flag indicating if feature is available to user. + * @returns header properties. + */ +export function configureHeader( + header: HeaderProps, + isFeatureFlag: boolean +): HeaderProps { + const navLinks = filterFeatureFlagNavigation(header.navLinks, isFeatureFlag); + return { + ...header, + navLinks, + }; +} + +/** + * Returns true if the given link is an internal link. + * @param link - Link. + * @returns true if the given link is an internal link. + */ +export function isClientSideNavigation(link: string): boolean { + return /^\/(?!\/)/.test(link); +} diff --git a/src/components/header/components/nav-link-dropdown-button/nav-link-dropdown-button.styles.ts b/src/components/header/components/nav-link-dropdown-button/nav-link-dropdown-button.styles.ts new file mode 100644 index 000000000..0a609e8d7 --- /dev/null +++ b/src/components/header/components/nav-link-dropdown-button/nav-link-dropdown-button.styles.ts @@ -0,0 +1,32 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import { Button as MButton } from "@mui/material"; + +interface Props { + isActive: boolean; +} + +export const NavLinkDropdownButton = styled(MButton, { + shouldForwardProp: (prop) => prop !== "isActive", +})` + background-color: ${({ theme }) => theme.palette.common.white}; + color: inherit; + gap: 0; + + &:active, + &:hover { + background-color: ${({ theme }) => theme.palette.smoke.light}; + } + + // Button is "active" i.e. menu is open. + ${({ isActive, theme }) => + isActive && + css` + background-color: ${theme.palette.smoke.light}; + `}; + + .MuiButton-endIcon { + margin-left: -3px; + margin-right: -6px; + } +`; diff --git a/src/components/header/components/nav-link-dropdown-button/nav-link-dropdown-button.tsx b/src/components/header/components/nav-link-dropdown-button/nav-link-dropdown-button.tsx new file mode 100644 index 000000000..9cac2e8b7 --- /dev/null +++ b/src/components/header/components/nav-link-dropdown-button/nav-link-dropdown-button.tsx @@ -0,0 +1,26 @@ +import ArrowDropDownRoundedIcon from "@mui/icons-material/ArrowDropDownRounded"; +import { ButtonProps as MButtonProps } from "@mui/material"; +import React from "react"; +import { NavLinkDropdownButton as Button } from "./nav-link-dropdown-button.styles"; + +export interface NavLinkDropdownButtonProps extends MButtonProps { + isActive: boolean; +} + +export const NavLinkDropdownButton = ({ + children, + isActive, + ...props /* Spread props to allow for Button specific props ButtonProps e.g. "onClick". */ +}: NavLinkDropdownButtonProps): JSX.Element => { + return ( + + ); +}; diff --git a/src/components/header/components/nav-link-menu/components/label-icon-menu-item/components/open-in-new/open-in-new-icon/open-in-new-icon.tsx b/src/components/header/components/nav-link-menu/components/label-icon-menu-item/components/open-in-new/open-in-new-icon/open-in-new-icon.tsx new file mode 100644 index 000000000..9fd23f90d --- /dev/null +++ b/src/components/header/components/nav-link-menu/components/label-icon-menu-item/components/open-in-new/open-in-new-icon/open-in-new-icon.tsx @@ -0,0 +1,21 @@ +import { SvgIcon, SvgIconProps } from "@mui/material"; +import React from "react"; + +/** + * Custom open in new icon. + */ + +export const OpenInNewIcon = ({ + fontSize = "xsmall", + viewBox = "0 0 18 18", + ...props /* Spread props to allow for Mui SvgIconProps specific prop overrides e.g. "htmlColor". */ +}: SvgIconProps): JSX.Element => { + return ( + + + + ); +}; diff --git a/src/components/header/components/nav-link-menu/components/label-icon-menu-item/label-icon-menu-item.styles.ts b/src/components/header/components/nav-link-menu/components/label-icon-menu-item/label-icon-menu-item.styles.ts new file mode 100644 index 000000000..52e19c20c --- /dev/null +++ b/src/components/header/components/nav-link-menu/components/label-icon-menu-item/label-icon-menu-item.styles.ts @@ -0,0 +1,7 @@ +import styled from "@emotion/styled"; + +export const Label = styled.div` + align-items: center; + display: flex; + gap: 4px; +`; diff --git a/src/components/header/components/nav-link-menu/components/label-icon-menu-item/label-icon-menu-item.tsx b/src/components/header/components/nav-link-menu/components/label-icon-menu-item/label-icon-menu-item.tsx new file mode 100644 index 000000000..760d49746 --- /dev/null +++ b/src/components/header/components/nav-link-menu/components/label-icon-menu-item/label-icon-menu-item.tsx @@ -0,0 +1,22 @@ +import React, { ElementType } from "react"; +import { OpenInNewIcon } from "./components/open-in-new/open-in-new-icon/open-in-new-icon"; +import { Label } from "./label-icon-menu-item.styles"; + +export interface LabelIconMenuItemProps { + Icon?: ElementType; + iconFontSize?: string; + label: string; +} + +export const LabelIconMenuItem = ({ + Icon = OpenInNewIcon, + iconFontSize = "xsmall", + label, +}: LabelIconMenuItemProps): JSX.Element => { + return ( + + ); +}; diff --git a/src/components/header/components/nav-link-menu/nav-link-menu.styles.ts b/src/components/header/components/nav-link-menu/nav-link-menu.styles.ts new file mode 100644 index 000000000..994def103 --- /dev/null +++ b/src/components/header/components/nav-link-menu/nav-link-menu.styles.ts @@ -0,0 +1,25 @@ +import styled from "@emotion/styled"; +import { Menu } from "@mui/material"; + +export const NavLinkMenu = styled(Menu)` + .MuiPaper-menu { + margin: 4px 0; + min-width: 144px; + border-color: ${({ theme }) => theme.palette.smoke.main}; + } + + && .MuiMenuItem-root { + margin: 0; + } + + .MuiListItemText-root { + display: grid; + gap: 4px; + + .MuiListItemText-secondary { + color: ${({ theme }) => theme.palette.ink.light}; + max-width: 290px; + white-space: normal; + } + } +`; diff --git a/src/components/header/components/nav-link-menu/nav-link-menu.tsx b/src/components/header/components/nav-link-menu/nav-link-menu.tsx new file mode 100644 index 000000000..e067c6d88 --- /dev/null +++ b/src/components/header/components/nav-link-menu/nav-link-menu.tsx @@ -0,0 +1,76 @@ +import { ListItemText, MenuItem as MMenuItem } from "@mui/material"; +import { navigate } from "gatsby"; +import React, { MouseEvent, ReactNode, useState } from "react"; +import { Target } from "../../../target/target.model"; +import { MenuItem } from "../../common/entities"; +import { isClientSideNavigation } from "../../common/utils"; +import { NavLinkDropdownButton } from "../nav-link-dropdown-button/nav-link-dropdown-button"; +import { NavLinkMenu as Menu } from "./nav-link-menu.styles"; + +export interface NavLinkMenuProps { + menuItems: MenuItem[]; + menuLabel: ReactNode; +} + +export const NavLinkMenu = ({ + menuItems, + menuLabel, +}: NavLinkMenuProps): JSX.Element => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const onOpenMenu = (event: MouseEvent): void => { + setAnchorEl(event.currentTarget); + }; + + const onCloseMenu = (): void => { + setAnchorEl(null); + }; + + return ( + <> + + {menuLabel} + + + {menuItems.map( + ({ description, label, target = Target.SELF, url }, i) => ( + { + setAnchorEl(null); + if (isClientSideNavigation(url)) { + navigate(url); + } else { + window.open(url, target); + } + }} + > + + + ) + )} + + + ); +}; diff --git a/src/components/header/components/nav-links/nav-links.tsx b/src/components/header/components/nav-links/nav-links.tsx index c9419fa39..22f7a6eba 100644 --- a/src/components/header/components/nav-links/nav-links.tsx +++ b/src/components/header/components/nav-links/nav-links.tsx @@ -1,7 +1,10 @@ -import { navigate } from "gatsby"; import { Box, Button } from "@mui/material"; +import { navigate } from "gatsby"; import React from "react"; +import { Target } from "../../../target/target.model"; import { NavLinkItem } from "../../common/entities"; +import { isClientSideNavigation } from "../../common/utils"; +import { NavLinkMenu } from "../nav-link-menu/nav-link-menu"; interface Props { center?: boolean; @@ -24,18 +27,26 @@ export default function NavLinks({ }} marginLeft={{ desktop: center ? undefined : 6, mobile: undefined }} > - {links.map(({ label, url }) => ( - - ))} + {links.map(({ label, menuItems, target = Target.SELF, url }, i) => + menuItems ? ( + + ) : ( + + ) + )} ); } diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx index adbc7d3e5..1d564a995 100644 --- a/src/components/header/header.tsx +++ b/src/components/header/header.tsx @@ -1,8 +1,11 @@ import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; import MenuRoundedIcon from "@mui/icons-material/MenuRounded"; import { Box, Divider, IconButton, Toolbar, Typography } from "@mui/material"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { FEATURES } from "../../hooks/useFeatureFlag/common/entities"; +import { useFeatureFlag } from "../../hooks/useFeatureFlag/useFeatureFlag"; import { Header as HeaderProps } from "./common/entities"; +import { configureHeader } from "./common/utils"; import Content from "./components/content/content"; import HeaderLogo from "./components/logo/header-logo"; import NavLinks from "./components/nav-links/nav-links"; @@ -25,6 +28,11 @@ interface Props { } export default function Header({ header, searchPath }: Props): JSX.Element { + const isFeatureFlag = useFeatureFlag(FEATURES.HEADER); + const configuredHeaderProps = useMemo( + () => configureHeader(header, isFeatureFlag), + [header, isFeatureFlag] + ); // Configure header. const { authenticationEnabled, logo, @@ -32,7 +40,7 @@ export default function Header({ header, searchPath }: Props): JSX.Element { searchEnabled, slogan, socials, - } = header; + } = configuredHeaderProps; const [drawerOpen, setDrawerOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const desktop = useBreakpointHelper( diff --git a/src/components/home/section-latest-updates/section-latest-updates.tsx b/src/components/home/section-latest-updates/section-latest-updates.tsx index f7c35a6aa..adadd871a 100644 --- a/src/components/home/section-latest-updates/section-latest-updates.tsx +++ b/src/components/home/section-latest-updates/section-latest-updates.tsx @@ -46,8 +46,8 @@ const SectionLatestUpdates: FC = (): JSX.Element => { {/* Content */} <>

News

- {newsCards.map((card) => ( - + {newsCards.map((card, i) => ( + ))} {/* CTAs */} @@ -70,8 +70,8 @@ const SectionLatestUpdates: FC = (): JSX.Element => { {/* Content */} <>

Events

- {eventCards.map((card) => ( - + {eventCards.map((card, i) => ( + ))} {/* CTAs */} diff --git a/src/components/layout.js b/src/components/layout.js index f8dd76ef2..1406336d4 100644 --- a/src/components/layout.js +++ b/src/components/layout.js @@ -10,6 +10,7 @@ import { CssBaseline, ThemeProvider } from "@mui/material"; import React, { useRef, useState } from "react"; // App dependencies +import { setFeatureFlags } from "../hooks/useFeatureFlag/common/utils"; import BannerPrivacy from "./banner-privacy/banner-privacy"; import Footer from "./footer/footer"; import Header from "./header/header"; @@ -28,6 +29,8 @@ import { getAppTheme } from "../theme/theme"; // Styles import "../styles/vars.module.css"; +setFeatureFlags(); + function Layout(props) { const { children, diff --git a/src/config/anvil/config.ts b/src/config/anvil/config.ts index 6ed0eb416..ba4beb774 100644 --- a/src/config/anvil/config.ts +++ b/src/config/anvil/config.ts @@ -1,6 +1,9 @@ -import { SiteConfig } from "../entities"; import logoAnvil from "../../../images/logo-anvil.png"; +import { LabelIconMenuItem } from "../../components/header/components/nav-link-menu/components/label-icon-menu-item/label-icon-menu-item"; +import { Target } from "../../components/target/target.model"; import { getDatasetsEnvironmentUrl } from "../../utils/environment/environment.service"; +import { getExploreURL } from "../../utils/explore.service"; +import { SiteConfig } from "../entities"; // Template constants const slogan = "NHGRI Analysis Visualization and Informatics Lab-space"; @@ -25,9 +28,33 @@ const config: SiteConfig = { url: "/learn", }, { + featureFlag: false, label: "Datasets", url: `${getDatasetsEnvironmentUrl()}data`, }, + { + featureFlag: true, + label: "Datasets", + menuItems: [ + { + description: + "An open-access view of studies, workspaces, and consortia.", + label: "Catalog", + url: `${getDatasetsEnvironmentUrl()}data`, + }, + { + description: + "Build, download, and export cross-study cohorts of open and managed access data.", + label: LabelIconMenuItem({ + iconFontSize: "small", + label: "Explorer", + }), + target: Target.BLANK, + url: getExploreURL(), + }, + ], + url: "", + }, { label: "Consortia", url: `${getDatasetsEnvironmentUrl()}consortia`, diff --git a/src/hooks/useFeatureFlag/common/entities.ts b/src/hooks/useFeatureFlag/common/entities.ts new file mode 100644 index 000000000..9b92fb2fc --- /dev/null +++ b/src/hooks/useFeatureFlag/common/entities.ts @@ -0,0 +1,8 @@ +export enum FEATURES { + HEADER = "header", +} + +export enum FLAG { + FALSE = "false", + TRUE = "true", +} diff --git a/src/hooks/useFeatureFlag/common/utils.ts b/src/hooks/useFeatureFlag/common/utils.ts new file mode 100644 index 000000000..07a06dc15 --- /dev/null +++ b/src/hooks/useFeatureFlag/common/utils.ts @@ -0,0 +1,37 @@ +import { FEATURES } from "./entities"; + +const setOfFeatureFlags = new Set(Object.values(FEATURES) as string[]); + +/** + * Return the value for the specified key. + * @param key - Key. + * @returns value. + */ +export function getLocalStorage(key: string): string | null { + if (typeof window === "undefined") return null; + return window?.localStorage?.getItem(key) ?? null; +} + +/** + * Set the value for the specified key. + * @param key - Key. + * @param value - Value. + */ +export function setLocalStorage(key: string, value: string): void { + if (typeof window === "undefined") return; + window?.localStorage?.setItem(key, value); +} + +/** + * Set feature flags from URL. + */ +export function setFeatureFlags(): void { + if (typeof window === "undefined") return; + // Grab the search params from the URL. + const params = new URLSearchParams(window.location.search); + for (const [key, value] of params) { + if (setOfFeatureFlags.has(key)) { + setLocalStorage(key, value); + } + } +} diff --git a/src/hooks/useFeatureFlag/useFeatureFlag.ts b/src/hooks/useFeatureFlag/useFeatureFlag.ts new file mode 100644 index 000000000..14f44a41a --- /dev/null +++ b/src/hooks/useFeatureFlag/useFeatureFlag.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { FEATURES, FLAG } from "./common/entities"; +import { getLocalStorage } from "./common/utils"; + +/** + * Determine if feature is available to user. + * @param featureFlag - Name of feature. + * @returns True if feature is available to user. + */ +export function useFeatureFlag(featureFlag: FEATURES): boolean { + /* Flag indicating if feature is available to user. */ + const [isEnabled, setIsEnabled] = useState(false); + + /* Update state of enabled flag and redirect user if feature is not available to them. */ + useEffect(() => { + const enabled = getLocalStorage(featureFlag) === FLAG.TRUE; + setIsEnabled(enabled); + }, [featureFlag]); + + return isEnabled; +} diff --git a/src/theme/definitions.d.ts b/src/theme/definitions.d.ts index 782419384..61d1e0b7b 100644 --- a/src/theme/definitions.d.ts +++ b/src/theme/definitions.d.ts @@ -56,6 +56,7 @@ declare module "@mui/material/styles" { "text-body-large-400-2lines": TypographyStyleOptions; "text-body-large-500": TypographyStyleOptions; "text-body-small-400": TypographyStyleOptions; + "text-body-small-400-2lines": TypographyStyleOptions; "text-body-small-500": TypographyStyleOptions; "text-heading": TypographyStyleOptions; "text-heading-large": TypographyStyleOptions; @@ -73,6 +74,7 @@ declare module "@mui/material/styles" { "text-body-large-400-2lines"?: TypographyStyleOptions; "text-body-large-500"?: TypographyStyleOptions; "text-body-small-400"?: TypographyStyleOptions; + "text-body-small-400-2lines"?: TypographyStyleOptions; "text-body-small-500"?: TypographyStyleOptions; "text-heading"?: TypographyStyleOptions; "text-heading-large"?: TypographyStyleOptions; @@ -95,6 +97,7 @@ declare module "@mui/material/Typography" { "text-body-large-400-2lines": true; "text-body-large-500": true; "text-body-small-400": true; + "text-body-small-400-2lines": true; "text-body-small-500": true; "text-heading": true; "text-heading-large": true; @@ -120,6 +123,7 @@ declare module "@mui/material/Button" { declare module "@mui/material/IconButton" { interface IconButtonPropsColorOverrides { ink: true; + inkLight: true; } interface IconButtonPropsSizeOverrides { diff --git a/src/theme/theme.ts b/src/theme/theme.ts index 144b8bd60..3bb0558e1 100644 --- a/src/theme/theme.ts +++ b/src/theme/theme.ts @@ -137,6 +137,12 @@ export const getAppTheme = (customTheme?: ThemeOptions): Theme => { fontWeight: 400, lineHeight: "16px", }, + "text-body-small-400-2lines": { + fontFamily: "Inter", + fontSize: 13, + fontWeight: 400, + lineHeight: "20px", + }, "text-body-small-500": { fontFamily: "Inter", fontSize: 13, @@ -637,6 +643,14 @@ export const getAppTheme = (customTheme?: ThemeOptions): Theme => { }, }, variants: [ + { + props: { + color: "inkLight", + }, + style: { + color: inkLight, + }, + }, { props: { size: "xsmall", diff --git a/src/utils/environment/environment-url.model.js b/src/utils/environment/environment-url.model.js index 452b4ff6b..c93f5b423 100644 --- a/src/utils/environment/environment-url.model.js +++ b/src/utils/environment/environment-url.model.js @@ -4,6 +4,8 @@ export const EnvironmentUrl = { "https://anvil-portal-prod-checkout.dev.clevercanary.com/", "ANVIL-PORTAL-CC-DEV": "https://anvilproject.dev.clevercanary.com/", "ANVIL-PROD": "https://prod.anvil.gi.ucsc.edu/", + "EXPLORE-DEV": "https://explore.anvilproject.dev.clevercanary.com/", + "EXPLORE-PROD": "https://prod.anvil.gi.ucsc.edu/", LOCAL: "http://localhost:8000/", MASTER: "https://anvilproject.org/", STAGING: "https://staging.anvilproject.org/", diff --git a/src/utils/explore.service.js b/src/utils/explore.service.js new file mode 100644 index 000000000..a4cc6d1bc --- /dev/null +++ b/src/utils/explore.service.js @@ -0,0 +1,16 @@ +import { EnvironmentUrl } from "./environment/environment-url.model"; +import { getCurrentEnvironment } from "./environment/environment.service"; + +const PROD_ENV = ["ANVIL-PROD", "MASTER", "PROD"]; + +/** + * Returns the explore URL for the given environment. + * @returns explore URL. + */ +export function getExploreURL() { + const currentEnvironment = getCurrentEnvironment(); + if (PROD_ENV.includes(currentEnvironment)) { + return `${EnvironmentUrl["EXPLORE-PROD"]}explore/datasets`; + } + return `${EnvironmentUrl["EXPLORE-DEV"]}datasets`; +}