1
0
mirror of https://github.com/checktheroads/hyperglass synced 2024-05-11 05:55:08 +00:00

Closes #140: Genericize footer links and menus and allow multiple definitions

This commit is contained in:
checktheroads
2021-05-30 01:05:07 -07:00
parent 69f21a4d64
commit c0914f6216
8 changed files with 157 additions and 72 deletions

View File

@@ -2,7 +2,6 @@
# Standard Library
import os
import copy
import json
from typing import Dict, List
from pathlib import Path
@@ -18,6 +17,7 @@ from hyperglass.log import (
enable_syslog_logging,
)
from hyperglass.util import set_app_path, set_cache_env, current_log_level
from hyperglass.defaults import CREDIT, DEFAULT_DETAILS
from hyperglass.constants import (
SUPPORTED_QUERY_TYPES,
PARSED_RESPONSE_FIELDS,
@@ -28,12 +28,6 @@ from hyperglass.util.files import check_path
from hyperglass.models.commands import Commands
from hyperglass.models.config.params import Params
from hyperglass.models.config.devices import Devices
from hyperglass.configuration.defaults import (
CREDIT,
DEFAULT_HELP,
DEFAULT_TERMS,
DEFAULT_DETAILS,
)
# Local
from .markdown import get_markdown
@@ -332,17 +326,6 @@ content_greeting = get_markdown(
content_vrf = _build_vrf_help()
content_help_params = copy.copy(content_params)
content_help_params["title"] = params.web.help_menu.title
content_help = get_markdown(
config_path=params.web.help_menu, default=DEFAULT_HELP, params=content_help_params
)
content_terms_params = copy.copy(content_params)
content_terms_params["title"] = params.web.terms.title
content_terms = get_markdown(
config_path=params.web.terms, default=DEFAULT_TERMS, params=content_terms_params
)
content_credit = CREDIT.format(version=__version__)
networks = _build_networks()
@@ -374,8 +357,6 @@ _frontend_params.update(
"networks": networks,
"parsed_data_fields": PARSED_RESPONSE_FIELDS,
"content": {
"help_menu": content_help,
"terms": content_terms,
"credit": content_credit,
"vrf": content_vrf,
"greeting": content_greeting,

View File

@@ -1,7 +1,7 @@
"""Validate branding configuration variables."""
# Standard Library
from typing import Union, Optional
from typing import Union, Optional, Sequence
from pathlib import Path
# Third Party
@@ -18,6 +18,7 @@ from pydantic import (
from pydantic.color import Color
# Project
from hyperglass.defaults import DEFAULT_HELP, DEFAULT_TERMS
from hyperglass.constants import DNS_OVER_HTTPS, FUNC_COLOR_MAP
# Local
@@ -31,6 +32,7 @@ TitleMode = constr(regex=("logo_only|text_only|logo_title|logo_subtitle|all"))
ColorMode = constr(regex=r"light|dark")
DOHProvider = constr(regex="|".join(DNS_OVER_HTTPS.keys()))
Title = constr(max_length=32)
Side = constr(regex=r"left|right")
class Analytics(HyperglassModel):
@@ -64,20 +66,36 @@ class Credit(HyperglassModel):
enable: StrictBool = True
class ExternalLink(HyperglassModel):
"""Validation model for external link."""
class Link(HyperglassModel):
"""Validation model for generic link."""
enable: StrictBool = True
title: StrictStr = "PeeringDB"
url: HttpUrl = "https://www.peeringdb.com/asn/{primary_asn}"
title: StrictStr
url: HttpUrl
show_icon: StrictBool = True
side: Side = "left"
order: StrictInt = 0
class HelpMenu(HyperglassModel):
"""Validation model for generic help menu."""
class Menu(HyperglassModel):
"""Validation model for generic menu."""
enable: StrictBool = True
file: Optional[FilePath]
title: StrictStr = "Help"
title: StrictStr
content: StrictStr
side: Side = "left"
order: StrictInt = 0
@validator("content")
def validate_content(cls, value):
"""Read content from file if a path is provided."""
if len(value) < 260:
path = Path(value)
if path.exists():
with path.open("r") as f:
return f.read()
else:
return value
return value
class Greeting(HyperglassModel):
@@ -107,14 +125,6 @@ class Logo(HyperglassModel):
height: Optional[Union[StrictInt, Percentage]]
class Terms(HyperglassModel):
"""Validation model for terms & conditions."""
enable: StrictBool = True
file: Optional[FilePath]
title: StrictStr = "Terms"
class Text(HyperglassModel):
"""Validation model for params.branding.text."""
@@ -236,11 +246,15 @@ class Web(HyperglassModel):
credit: Credit = Credit()
dns_provider: DnsOverHttps = DnsOverHttps()
external_link: ExternalLink = ExternalLink()
links: Sequence[Link] = [
Link(title="PeeringDB", url="https://www.peeringdb.com/asn/{primary_asn}")
]
menus: Sequence[Menu] = [
Menu(title="Terms", content=DEFAULT_TERMS),
Menu(title="Help", content=DEFAULT_HELP),
]
greeting: Greeting = Greeting()
help_menu: HelpMenu = HelpMenu()
logo: Logo = Logo()
opengraph: OpenGraph = OpenGraph()
terms: Terms = Terms()
text: Text = Text()
theme: Theme = Theme()

View File

@@ -1,16 +1,37 @@
import { useMemo } from 'react';
import { Button, Menu, MenuButton, MenuList } from '@chakra-ui/react';
import { Markdown } from '~/components';
import { useColorValue, useBreakpointValue } from '~/context';
import { useOpposingColor } from '~/hooks';
import { useColorValue, useBreakpointValue, useConfig } from '~/context';
import { useOpposingColor, useStrf } from '~/hooks';
import type { IConfig } from '~/types';
import type { TFooterButton } from './types';
/**
* Filter the configuration object based on values that are strings for formatting.
*/
function getConfigFmt(config: IConfig): Record<string, string> {
const fmt = {} as Record<string, string>;
for (const [k, v] of Object.entries(config)) {
if (typeof v === 'string') {
fmt[k] = v;
}
}
return fmt;
}
export const FooterButton: React.FC<TFooterButton> = (props: TFooterButton) => {
const { content, title, side, ...rest } = props;
const config = useConfig();
const fmt = useMemo(() => getConfigFmt(config), []);
const fmtContent = useStrf(content, fmt);
const placement = side === 'left' ? 'top' : side === 'right' ? 'top-end' : undefined;
const bg = useColorValue('white', 'gray.900');
const color = useOpposingColor(bg);
const size = useBreakpointValue({ base: 'xs', lg: 'sm' });
return (
<Menu placement={placement} preventOverflow isLazy>
<MenuButton
@@ -32,11 +53,12 @@ export const FooterButton: React.FC<TFooterButton> = (props: TFooterButton) => {
boxShadow="2xl"
textAlign="left"
overflowY="auto"
whiteSpace="normal"
mx={{ base: 1, lg: 2 }}
maxW={{ base: '100%', lg: '50vw' }}
{...rest}
>
<Markdown content={content} />
<Markdown content={fmtContent} />
</MenuList>
</Menu>
);

View File

@@ -1,27 +1,43 @@
import { useMemo } from 'react';
import dynamic from 'next/dynamic';
import { Button, Flex, Link, Icon, HStack, useToken } from '@chakra-ui/react';
import { Flex, Icon, HStack, useToken } from '@chakra-ui/react';
import { If } from '~/components';
import { useConfig, useMobile, useColorValue, useBreakpointValue } from '~/context';
import { useStrf } from '~/hooks';
import { FooterButton } from './button';
import { ColorModeToggle } from './colorMode';
import { FooterLink } from './link';
import { isLink, isMenu } from './types';
import type { ButtonProps, LinkProps } from '@chakra-ui/react';
import type { TLink, TMenu } from '~/types';
const CodeIcon = dynamic<MeronexIcon>(() => import('@meronex/icons/fi').then(i => i.FiCode));
const ExtIcon = dynamic<MeronexIcon>(() => import('@meronex/icons/go').then(i => i.GoLinkExternal));
function buildItems(links: TLink[], menus: TMenu[]): [(TLink | TMenu)[], (TLink | TMenu)[]] {
const leftLinks = links.filter(link => link.side === 'left');
const leftMenus = menus.filter(menu => menu.side === 'left');
const rightLinks = links.filter(link => link.side === 'right');
const rightMenus = menus.filter(menu => menu.side === 'right');
const left = [...leftLinks, ...leftMenus].sort((a, b) => (a.order > b.order ? 1 : -1));
const right = [...rightLinks, ...rightMenus].sort((a, b) => (a.order > b.order ? 1 : -1));
return [left, right];
}
export const Footer: React.FC = () => {
const { web, content, primary_asn } = useConfig();
const footerBg = useColorValue('blackAlpha.50', 'whiteAlpha.100');
const footerColor = useColorValue('black', 'white');
const extUrl = useStrf(web.external_link.url, { primary_asn }) ?? '/';
const size = useBreakpointValue({ base: useToken('sizes', 4), lg: useToken('sizes', 6) });
const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' });
const isMobile = useMobile();
const [left, right] = useMemo(() => buildItems(web.links, web.menus), []);
return (
<HStack
px={6}
@@ -30,30 +46,42 @@ export const Footer: React.FC = () => {
zIndex={1}
as="footer"
bg={footerBg}
whiteSpace="nowrap"
color={footerColor}
spacing={{ base: 8, lg: 6 }}
d={{ base: 'inline-block', lg: 'flex' }}
overflowY={{ base: 'auto', lg: 'unset' }}
justifyContent={{ base: 'center', lg: 'space-between' }}
>
<If c={web.terms.enable}>
<FooterButton side="left" content={content.terms} title={web.terms.title} />
</If>
<If c={web.help_menu.enable}>
<FooterButton side="left" content={content.help_menu} title={web.help_menu.title} />
</If>
<If c={web.external_link.enable}>
<Button
as={Link}
isExternal
href={extUrl}
size={btnSize}
variant="ghost"
rightIcon={<ExtIcon />}
aria-label={web.external_link.title}
>
{web.external_link.title}
</Button>
</If>
{left.map(item => {
if (isLink(item)) {
const url = useStrf(item.url, { primary_asn }) ?? '/';
const icon: Partial<ButtonProps & LinkProps> = {};
if (item.show_icon) {
icon.rightIcon = <ExtIcon />;
}
return <FooterLink key={item.title} href={url} title={item.title} {...icon} />;
} else if (isMenu(item)) {
return (
<FooterButton key={item.title} side="left" content={item.content} title={item.title} />
);
}
})}
{!isMobile && <Flex p={0} flex="1 0 auto" maxWidth="100%" mr="auto" />}
{right.map(item => {
if (isLink(item)) {
const url = useStrf(item.url, { primary_asn }) ?? '/';
const icon: Partial<ButtonProps & LinkProps> = {};
if (item.show_icon) {
icon.rightIcon = <ExtIcon />;
}
return <FooterLink href={url} title={item.title} {...icon} />;
} else if (isMenu(item)) {
return <FooterButton side="right" content={item.content} title={item.title} />;
}
})}
<If c={web.credit.enable}>
<FooterButton
side="right"

View File

@@ -0,0 +1,13 @@
import { Button, Link, useBreakpointValue } from '@chakra-ui/react';
import type { TFooterLink } from './types';
export const FooterLink: React.FC<TFooterLink> = (props: TFooterLink) => {
const { title } = props;
const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' });
return (
<Button as={Link} isExternal size={btnSize} variant="ghost" aria-label={title} {...props}>
{title}
</Button>
);
};

View File

@@ -1,4 +1,5 @@
import type { ButtonProps, MenuListProps } from '@chakra-ui/react';
import type { ButtonProps, LinkProps, MenuListProps } from '@chakra-ui/react';
import type { TLink, TMenu } from '~/types';
type TFooterSide = 'left' | 'right';
@@ -8,8 +9,18 @@ export interface TFooterButton extends Omit<MenuListProps, 'title'> {
content: string;
}
export type TFooterLink = ButtonProps & LinkProps & { title: string };
export type TFooterItems = 'help' | 'credit' | 'terms';
export interface TColorModeToggle extends ButtonProps {
size?: string;
}
export function isLink(item: TLink | TMenu): item is TLink {
return 'url' in item;
}
export function isMenu(item: TLink | TMenu): item is TMenu {
return 'content' in item;
}

View File

@@ -2,6 +2,8 @@ import type { Theme } from './theme';
export type TQueryFields = 'query_type' | 'query_target' | 'query_location' | 'query_vrf';
type TSide = 'left' | 'right';
export interface IConfigMessages {
no_input: string;
acl_denied: string;
@@ -62,10 +64,26 @@ export interface TConfigWebLogo {
dark_format: string;
}
export interface TLink {
title: string;
url: string;
show_icon: boolean;
side: TSide;
order: number;
}
export interface TMenu {
title: string;
content: string;
side: TSide;
order: number;
}
export interface IConfigWeb {
credit: { enable: boolean };
dns_provider: { name: string; url: string };
external_link: { enable: boolean; title: string; url: string };
links: TLink[];
menus: TMenu[];
greeting: TConfigGreeting;
help_menu: { enable: boolean; title: string };
logo: TConfigWebLogo;
@@ -149,8 +167,6 @@ export interface TQueryContent {
}
export interface IConfigContent {
help_menu: string;
terms: string;
credit: string;
greeting: string;
vrf: {