From bd0eb65ffc4782bcd88b7323e934dfe95db957eb Mon Sep 17 00:00:00 2001 From: thatmattlove Date: Tue, 14 Dec 2021 01:31:45 -0700 Subject: [PATCH] Closes #173: Implement customizable highlighting of text output --- hyperglass/models/config/web.py | 52 ++++++++++------- .../ui/components/output/highlighted.tsx | 57 +++++++++++++++++++ hyperglass/ui/components/output/text.tsx | 11 +++- hyperglass/ui/package.json | 1 + hyperglass/ui/types/config.ts | 8 +++ hyperglass/ui/yarn.lock | 5 ++ 6 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 hyperglass/ui/components/output/highlighted.tsx diff --git a/hyperglass/models/config/web.py b/hyperglass/models/config/web.py index 4704f001..d534e159 100644 --- a/hyperglass/models/config/web.py +++ b/hyperglass/models/config/web.py @@ -61,7 +61,7 @@ class Menu(HyperglassModel): order: StrictInt = 0 @validator("content") - def validate_content(cls, value): + def validate_content(cls: "Menu", value: str) -> str: """Read content from file if a path is provided.""" if len(value) < 260: @@ -135,14 +135,14 @@ class Text(HyperglassModel): ip_button: StrictStr = "My IP" @validator("title_mode") - def validate_title_mode(cls, value): + def validate_title_mode(cls: "Text", value: str) -> str: """Set legacy logo_title to logo_subtitle.""" if value == "logo_title": value = "logo_subtitle" return value @validator("cache_prefix") - def validate_cache_prefix(cls, value): + def validate_cache_prefix(cls: "Text", value: str) -> str: """Ensure trailing whitespace.""" return " ".join(value.split()) + " " @@ -172,21 +172,16 @@ class ThemeColors(HyperglassModel): danger: t.Optional[Color] @validator(*FUNC_COLOR_MAP.keys(), pre=True, always=True) - def validate_colors(cls, value, values, field): - """Set default functional color mapping. - - Arguments: - value {str|None} -- Functional color - values {str} -- Already-validated colors - Returns: - {str} -- Mapped color. - """ + def validate_colors( + cls: "ThemeColors", value: str, values: t.Dict[str, t.Optional[str]], field + ) -> str: + """Set default functional color mapping.""" if value is None: default_color = FUNC_COLOR_MAP[field.name] value = str(values[default_color]) return value - def dict(self, *args, **kwargs): + def dict(self, *args: t.Any, **kwargs: t.Any) -> t.Dict[str, str]: """Return dict for colors only.""" return {k: v.as_hex() for k, v in self.__dict__.items()} @@ -213,20 +208,32 @@ class DnsOverHttps(HyperglassModel): url: StrictStr = "" @root_validator - def validate_dns(cls, values): - """Assign url field to model based on selected provider. - - Arguments: - values {dict} -- Dict of selected provider - - Returns: - {dict} -- Dict with url attribute - """ + def validate_dns(cls: "DnsOverHttps", values: t.Dict[str, str]) -> t.Dict[str, str]: + """Assign url field to model based on selected provider.""" provider = values["name"] values["url"] = DNS_OVER_HTTPS[provider] return values +class HighlightPattern(HyperglassModel): + """Validation model for highlight pattern configuration.""" + + pattern: StrictStr + label: t.Optional[StrictStr] = None + color: StrictStr = "primary" + + @validator("color") + def validate_color(cls: "HighlightPattern", value: str) -> str: + """Ensure highlight color is a valid theme color.""" + colors = list(ThemeColors.__fields__.keys()) + color_list = "\n - ".join(("", *colors)) + if value not in colors: + raise ValueError( + "{!r} is not a supported color. Must be one of:{!s}".format(value, color_list) + ) + return value + + class Web(HyperglassModel): """Validation model for all web/browser-related configuration.""" @@ -247,6 +254,7 @@ class Web(HyperglassModel): location_display_mode: LocationDisplayMode = "auto" custom_javascript: t.Optional[FilePath] custom_html: t.Optional[FilePath] + highlight: t.List[HighlightPattern] = [] class WebPublic(Web): diff --git a/hyperglass/ui/components/output/highlighted.tsx b/hyperglass/ui/components/output/highlighted.tsx new file mode 100644 index 00000000..359e4962 --- /dev/null +++ b/hyperglass/ui/components/output/highlighted.tsx @@ -0,0 +1,57 @@ +import React, { memo } from 'react'; +import { Badge, Tooltip, useStyleConfig } from '@chakra-ui/react'; +import isEqual from 'react-fast-compare'; +import replace from 'react-string-replace'; + +import type { TooltipProps } from '@chakra-ui/react'; +import type { Highlight as HighlightConfig } from '~/types'; + +interface HighlightedProps { + patterns: HighlightConfig[]; + children: string; +} + +interface HighlightProps { + label: string | null; + colorScheme: string; + children: React.ReactNode; +} + +const Highlight = (props: HighlightProps): JSX.Element => { + const { colorScheme, label, children } = props; + const { bg, color } = useStyleConfig('Button', { colorScheme }) as TooltipProps; + return ( + + {children} + + ); +}; + +const _Highlighted = (props: HighlightedProps): JSX.Element => { + const { patterns, children } = props; + let result: React.ReactNodeArray = []; + let times: number = 0; + + for (const config of patterns) { + let toReplace: string | React.ReactNodeArray = children; + if (times !== 0) { + toReplace = result; + } + result = replace(toReplace, new RegExp(`(${config.pattern})`, 'gm'), (m, i) => ( + + {m} + + )); + times++; + } + + return ( + <> + {result.map(r => ( + <>{r} + ))} + + ); +}; + +export const Highlighted = memo(_Highlighted, isEqual); diff --git a/hyperglass/ui/components/output/text.tsx b/hyperglass/ui/components/output/text.tsx index 8fa794ca..976bd178 100644 --- a/hyperglass/ui/components/output/text.tsx +++ b/hyperglass/ui/components/output/text.tsx @@ -1,5 +1,6 @@ import { Box } from '@chakra-ui/react'; -import { useColorValue } from '~/context'; +import { useColorValue, useConfig } from '~/context'; +import { Highlighted } from './highlighted'; import type { TTextOutput } from './types'; @@ -11,6 +12,10 @@ export const TextOutput: React.FC = (props: TTextOutput) => { const selectionBg = useColorValue('black', 'white'); const selectionColor = useColorValue('white', 'black'); + const { + web: { highlight }, + } = useConfig(); + return ( = (props: TTextOutput) => { }} {...rest} > - {children.split('\\n').join('\n').replace(/\n\n/g, '\n')} + + {children.split('\\n').join('\n').replace(/\n\n/g, '\n')} + ); }; diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index ab154b50..b32efb5a 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -42,6 +42,7 @@ "react-markdown": "^5.0.3", "react-query": "^3.16.0", "react-select": "^5.2.1", + "react-string-replace": "^v0.5.0", "react-table": "^7.7.0", "remark-gfm": "^1.0.0", "string-format": "^2.0.0", diff --git a/hyperglass/ui/types/config.ts b/hyperglass/ui/types/config.ts index 651af93c..63ba68a3 100644 --- a/hyperglass/ui/types/config.ts +++ b/hyperglass/ui/types/config.ts @@ -85,6 +85,12 @@ interface _Credit { enable: boolean; } +interface _Highlight { + pattern: string; + label: string | null; + color: string; +} + interface _Web { credit: _Credit; dns_provider: { name: string; url: string }; @@ -97,6 +103,7 @@ interface _Web { text: _Text; theme: _ThemeConfig; location_display_mode: 'auto' | 'gallery' | 'dropdown'; + highlight: _Highlight[]; } type _DirectiveBase = { @@ -201,3 +208,4 @@ export type Greeting = CamelCasedProperties<_Greeting>; export type Logo = CamelCasedProperties<_Logo>; export type Link = CamelCasedProperties<_Link>; export type Menu = CamelCasedProperties<_Menu>; +export type Highlight = CamelCasedProperties<_Highlight>; diff --git a/hyperglass/ui/yarn.lock b/hyperglass/ui/yarn.lock index 98657ac3..4cf1033c 100644 --- a/hyperglass/ui/yarn.lock +++ b/hyperglass/ui/yarn.lock @@ -7058,6 +7058,11 @@ react-simple-animate@^3.3.12: resolved "https://registry.yarnpkg.com/react-simple-animate/-/react-simple-animate-3.3.12.tgz#ddea0f230feb3c1f069fbdb0a26e735e0b233265" integrity sha512-lFXjxD6ficcpOMsHfcDs1jqdkCve6jNlJnubOCzVOLswFDRANsaLN4KwpezDuliEFz8Q1zyj4J7Tmj3KMRnPcg== +react-string-replace@^v0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/react-string-replace/-/react-string-replace-0.5.0.tgz#d563ea608f98c449eea5eb3c7511fe806dba7025" + integrity sha512-xtfotmm+Gby5LjfYg3s5+eT6bnwgOIdZRAdHTouLY/7SicDtX4JXjG7CAGaGDcS0ax4nsaaEVNRxArSa8BgQKw== + react-style-singleton@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.1.1.tgz#ce7f90b67618be2b6b94902a30aaea152ce52e66"