From 196b3e04005b5c2a6046ef6c8c9b3f281e88cc8e Mon Sep 17 00:00:00 2001 From: thatmattlove Date: Mon, 6 Dec 2021 14:33:20 -0700 Subject: [PATCH] implement dynamic icon component & migrate back to react-icons --- hyperglass/ui/components/debugger.tsx | 21 +- hyperglass/ui/components/footer/button.tsx | 1 + hyperglass/ui/components/footer/colorMode.tsx | 12 +- hyperglass/ui/components/footer/footer.tsx | 14 +- .../ui/components/form/resolvedTarget.tsx | 28 +-- hyperglass/ui/components/form/userIP.tsx | 13 +- hyperglass/ui/components/help/modal.tsx | 7 +- .../ui/components/layout/resetButton.tsx | 12 +- hyperglass/ui/components/output/fields.tsx | 38 ++-- hyperglass/ui/components/path/button.tsx | 12 +- hyperglass/ui/components/path/controls.tsx | 24 ++- .../ui/components/results/copyButton.tsx | 11 +- hyperglass/ui/components/results/header.tsx | 7 +- .../ui/components/results/individual.tsx | 8 +- .../ui/components/results/requeryButton.tsx | 8 +- hyperglass/ui/components/submit/submit.tsx | 5 +- hyperglass/ui/components/table/main.tsx | 37 +--- .../ui/components/util/dynamic-icon.tsx | 181 ++++++++++++++++++ hyperglass/ui/components/util/index.ts | 1 + hyperglass/ui/hooks/useFormState.ts | 2 +- hyperglass/ui/package.json | 5 +- hyperglass/ui/util/state.ts | 4 +- hyperglass/ui/yarn.lock | 33 ++-- 23 files changed, 311 insertions(+), 173 deletions(-) create mode 100644 hyperglass/ui/components/util/dynamic-icon.tsx diff --git a/hyperglass/ui/components/debugger.tsx b/hyperglass/ui/components/debugger.tsx index 9255ece..5218779 100644 --- a/hyperglass/ui/components/debugger.tsx +++ b/hyperglass/ui/components/debugger.tsx @@ -12,11 +12,8 @@ import { useDisclosure, ModalCloseButton, } from '@chakra-ui/react'; -import { HiOutlineDownload as RefreshIcon } from '@meronex/icons/hi'; -import { IosColorPalette as ThemeIcon } from '@meronex/icons/ios'; -import { MdcCodeJson as ConfigIcon } from '@meronex/icons/mdc'; import { useConfig, useColorValue, useBreakpointValue } from '~/context'; -import { CodeBlock } from '~/components'; +import { CodeBlock, DynamicIcon } from '~/components'; import { useHyperglassConfig } from '~/hooks'; import type { UseDisclosureReturn } from '@chakra-ui/react'; @@ -75,16 +72,26 @@ export const Debugger: React.FC = () => { {colorMode.toUpperCase()} - - diff --git a/hyperglass/ui/components/footer/footer.tsx b/hyperglass/ui/components/footer/footer.tsx index f38d664..72de28b 100644 --- a/hyperglass/ui/components/footer/footer.tsx +++ b/hyperglass/ui/components/footer/footer.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; -import dynamic from 'next/dynamic'; -import { Flex, Icon, HStack, useToken } from '@chakra-ui/react'; -import { If } from '~/components'; +import { Flex, HStack, useToken } from '@chakra-ui/react'; +import { DynamicIcon, If } from '~/components'; import { useConfig, useMobile, useColorValue, useBreakpointValue } from '~/context'; import { useStrf } from '~/hooks'; import { FooterButton } from './button'; @@ -12,9 +11,6 @@ import { isLink, isMenu } from './types'; import type { ButtonProps, LinkProps } from '@chakra-ui/react'; import type { Link, Menu } from '~/types'; -const CodeIcon = dynamic(() => import('@meronex/icons/fi').then(i => i.FiCode)); -const ExtIcon = dynamic(() => import('@meronex/icons/go').then(i => i.GoLinkExternal)); - function buildItems(links: Link[], menus: Menu[]): [(Link | Menu)[], (Link | Menu)[]] { const leftLinks = links.filter(link => link.side === 'left'); const leftMenus = menus.filter(menu => menu.side === 'left'); @@ -61,7 +57,7 @@ export const Footer: React.FC = () => { const icon: Partial = {}; if (item.showIcon) { - icon.rightIcon = ; + icon.rightIcon = ; } return ; } else if (isMenu(item)) { @@ -77,7 +73,7 @@ export const Footer: React.FC = () => { const icon: Partial = {}; if (item.showIcon) { - icon.rightIcon = ; + icon.rightIcon = ; } return ; } else if (isMenu(item)) { @@ -91,7 +87,7 @@ export const Footer: React.FC = () => { key="credit" side="right" content={content.credit} - title={} + title={} /> diff --git a/hyperglass/ui/components/form/resolvedTarget.tsx b/hyperglass/ui/components/form/resolvedTarget.tsx index 030ce77..d7ee705 100644 --- a/hyperglass/ui/components/form/resolvedTarget.tsx +++ b/hyperglass/ui/components/form/resolvedTarget.tsx @@ -1,20 +1,12 @@ import { useMemo } from 'react'; -import dynamic from 'next/dynamic'; -import { Button, chakra, Stack, Text, VStack } from '@chakra-ui/react'; +import { Button, Stack, Text, VStack } from '@chakra-ui/react'; +import { DynamicIcon } from '~/components'; import { useConfig, useColorValue } from '~/context'; import { useStrf, useDNSQuery, useFormState } from '~/hooks'; import type { DnsOverHttps } from '~/types'; import type { ResolvedTargetProps } from './types'; -const RightArrow = chakra( - dynamic(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleRight)), -); - -const LeftArrow = chakra( - dynamic(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleLeft)), -); - function findAnswer(data: DnsOverHttps.Response | undefined): string { let answer = ''; if (typeof data !== 'undefined') { @@ -68,16 +60,12 @@ export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => { } const hasAnswer = useMemo( - () => (!isError4 || !isError6) && (answer4 !== '' || answer6 !== ''), + () => (!isError4 || !isError6) && (answer4 || answer6), [answer4, answer6, isError4, isError6], ); - const showA = useMemo( - () => !isLoading4 && !isError4 && answer4 !== '', - [isLoading4, isError4, answer4], - ); - + const showA = useMemo(() => !isLoading4 && !isError4 && answer4, [isLoading4, isError4, answer4]); const showAAAA = useMemo( - () => !isLoading6 && !isError6 && answer6 !== '', + () => !isLoading6 && !isError6 && answer6, [isLoading6, isError6, answer6], ); @@ -102,7 +90,7 @@ export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => { colorScheme="primary" justifyContent="space-between" onClick={() => selectTarget(answer4)} - rightIcon={} + rightIcon={} > {answer4} @@ -116,7 +104,7 @@ export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => { colorScheme="secondary" justifyContent="space-between" onClick={() => selectTarget(answer6)} - rightIcon={} + rightIcon={} > {answer6} @@ -135,7 +123,7 @@ export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => { variant="outline" size="sm" onClick={errorClose} - leftIcon={} + leftIcon={} > {web.text.fqdnErrorButton} diff --git a/hyperglass/ui/components/form/userIP.tsx b/hyperglass/ui/components/form/userIP.tsx index edbcea1..25f9390 100644 --- a/hyperglass/ui/components/form/userIP.tsx +++ b/hyperglass/ui/components/form/userIP.tsx @@ -1,16 +1,11 @@ import { useMemo } from 'react'; -import dynamic from 'next/dynamic'; -import { Button, chakra, Stack, Text, VStack, useDisclosure } from '@chakra-ui/react'; -import { Prompt } from '~/components'; +import { Button, Stack, Text, VStack, useDisclosure } from '@chakra-ui/react'; +import { DynamicIcon, Prompt } from '~/components'; import { useConfig, useColorValue } from '~/context'; import { useStrf, useWtf } from '~/hooks'; import type { UserIPProps } from './types'; -const RightArrow = chakra( - dynamic(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleRight)), -); - export const UserIP = (props: UserIPProps): JSX.Element => { const { setTarget } = props; const { onOpen, ...disclosure } = useDisclosure(); @@ -67,7 +62,7 @@ export const UserIP = (props: UserIPProps): JSX.Element => { ipv4?.data?.ip && setTarget(ipv4.data.ip); disclosure.onClose(); }} - rightIcon={} + rightIcon={} > {ipv4?.data?.ip ?? noIPv4} @@ -85,7 +80,7 @@ export const UserIP = (props: UserIPProps): JSX.Element => { ipv6?.data?.ip && setTarget(ipv6.data.ip); disclosure.onClose(); }} - rightIcon={} + rightIcon={} > {ipv6?.data?.ip ?? noIPv6} diff --git a/hyperglass/ui/components/help/modal.tsx b/hyperglass/ui/components/help/modal.tsx index faff30a..3d59bba 100644 --- a/hyperglass/ui/components/help/modal.tsx +++ b/hyperglass/ui/components/help/modal.tsx @@ -1,4 +1,3 @@ -import dynamic from 'next/dynamic'; import { Modal, ScaleFade, @@ -10,14 +9,12 @@ import { useDisclosure, ModalCloseButton, } from '@chakra-ui/react'; -import { Markdown } from '~/components'; +import { DynamicIcon, Markdown } from '~/components'; import { useColorValue } from '~/context'; import { isQueryContent } from '~/types'; import type { THelpModal } from './types'; -const Info = dynamic(() => import('@meronex/icons/fi').then(i => i.FiInfo)); - export const HelpModal: React.FC = (props: THelpModal) => { const { visible, item, name, ...rest } = props; const { isOpen, onOpen, onClose } = useDisclosure(); @@ -36,7 +33,7 @@ export const HelpModal: React.FC = (props: THelpModal) => { minW={3} size="md" variant="link" - icon={} + icon={} onClick={onOpen} colorScheme="blue" aria-label={`${name}_help`} diff --git a/hyperglass/ui/components/layout/resetButton.tsx b/hyperglass/ui/components/layout/resetButton.tsx index ce1eee7..1b7f8ee 100644 --- a/hyperglass/ui/components/layout/resetButton.tsx +++ b/hyperglass/ui/components/layout/resetButton.tsx @@ -1,14 +1,11 @@ -import dynamic from 'next/dynamic'; -import { Flex, Icon, IconButton } from '@chakra-ui/react'; +import { Flex, IconButton } from '@chakra-ui/react'; import { AnimatePresence } from 'framer-motion'; -import { AnimatedDiv } from '~/components'; +import { AnimatedDiv, DynamicIcon } from '~/components'; import { useColorValue } from '~/context'; import { useOpposingColor, useFormState } from '~/hooks'; import type { TResetButton } from './types'; -const LeftArrow = dynamic(() => import('@meronex/icons/fa').then(i => i.FaAngleLeft)); - export const ResetButton = (props: TResetButton): JSX.Element => { const { developerMode, resetForm, ...rest } = props; const status = useFormState(s => s.status); @@ -34,11 +31,12 @@ export const ResetButton = (props: TResetButton): JSX.Element => { > } + icon={} /> diff --git a/hyperglass/ui/components/output/fields.tsx b/hyperglass/ui/components/output/fields.tsx index 0e275af..eff8061 100644 --- a/hyperglass/ui/components/output/fields.tsx +++ b/hyperglass/ui/components/output/fields.tsx @@ -1,15 +1,9 @@ import { forwardRef } from 'react'; -import { Icon, Text, Box, Tooltip, Menu, MenuButton, MenuList, Link } from '@chakra-ui/react'; -import { CgMoreO as More } from '@meronex/icons/cg'; -import { BisError as Warning } from '@meronex/icons/bi'; -import { MdCancel as NotAllowed } from '@meronex/icons/md'; -import { RiHome2Fill as End } from '@meronex/icons/ri'; -import { BsQuestionCircleFill as Question } from '@meronex/icons/bs'; -import { FaCheckCircle as Check, FaChevronRight as ChevronRight } from '@meronex/icons/fa'; +import { Text, Box, Tooltip, Menu, MenuButton, MenuList, Link } from '@chakra-ui/react'; import dayjs from 'dayjs'; import relativeTimePlugin from 'dayjs/plugin/relativeTime'; import utcPlugin from 'dayjs/plugin/utc'; -import { If } from '~/components'; +import { If, DynamicIcon } from '~/components'; import { useConfig, useColorValue } from '~/context'; import { useOpposingColor } from '~/hooks'; @@ -41,10 +35,10 @@ export const Active: React.FC = (props: TActive) => { return ( <> - + - + ); @@ -86,7 +80,7 @@ export const ASPath: React.FC = (props: TASPath) => { ); if (path.length === 0) { - return ; + return ; } const paths = [] as JSX.Element[]; @@ -95,7 +89,13 @@ export const ASPath: React.FC = (props: TASPath) => { const asnStr = String(asn); i !== 0 && paths.push( - , + , ); paths.push( @@ -117,14 +117,14 @@ export const Communities: React.FC = (props: TCommunities) => { - + - + = ( ], ); const color = useOpposingColor(bg[+active][state]); - const icon = [NotAllowed, Check, Warning, Question]; + + const icon = [ + { md: 'MdCancel' }, + { fa: 'FaCheckCircle' }, + { bi: 'BisError' }, + { bs: 'BsQuestionCircleFill' }, + ] as Record[]; const text = [ web.text.rpkiInvalid, @@ -181,7 +187,7 @@ const _RPKIState: React.ForwardRefRenderFunction = ( color={color} > - + ); diff --git a/hyperglass/ui/components/path/button.tsx b/hyperglass/ui/components/path/button.tsx index 94723b4..d112a50 100644 --- a/hyperglass/ui/components/path/button.tsx +++ b/hyperglass/ui/components/path/button.tsx @@ -1,18 +1,14 @@ -import dynamic from 'next/dynamic'; -import { Button, Icon, Tooltip } from '@chakra-ui/react'; +import { Button, Tooltip } from '@chakra-ui/react'; +import { DynamicIcon } from '~/components'; import type { TPathButton } from './types'; -const PathIcon = dynamic(() => - import('@meronex/icons/bi').then(i => i.BisNetworkChart), -); - -export const PathButton: React.FC = (props: TPathButton) => { +export const PathButton = (props: TPathButton): JSX.Element => { const { onOpen } = props; return ( ); diff --git a/hyperglass/ui/components/path/controls.tsx b/hyperglass/ui/components/path/controls.tsx index 4d6e13d..0d002c6 100644 --- a/hyperglass/ui/components/path/controls.tsx +++ b/hyperglass/ui/components/path/controls.tsx @@ -1,10 +1,6 @@ -import dynamic from 'next/dynamic'; import { ButtonGroup, IconButton } from '@chakra-ui/react'; import { useZoomPanHelper } from 'react-flow-renderer'; - -const Plus = dynamic(() => import('@meronex/icons/fi').then(i => i.FiPlus)); -const Minus = dynamic(() => import('@meronex/icons/fi').then(i => i.FiMinus)); -const Square = dynamic(() => import('@meronex/icons/fi').then(i => i.FiSquare)); +import { DynamicIcon } from '~/components'; export const Controls: React.FC = () => { const { fitView, zoomIn, zoomOut } = useZoomPanHelper(); @@ -20,9 +16,21 @@ export const Controls: React.FC = () => { variant="solid" colorScheme="secondary" > - } onClick={() => zoomIn()} aria-label="Zoom In" /> - } onClick={() => zoomOut()} aria-label="Zoom Out" /> - } onClick={() => fitView()} aria-label="Fit Nodes" /> + } + onClick={() => zoomIn()} + aria-label="Zoom In" + /> + } + onClick={() => zoomOut()} + aria-label="Zoom Out" + /> + } + onClick={() => fitView()} + aria-label="Fit Nodes" + /> ); }; diff --git a/hyperglass/ui/components/results/copyButton.tsx b/hyperglass/ui/components/results/copyButton.tsx index bc031c3..74a2eaf 100644 --- a/hyperglass/ui/components/results/copyButton.tsx +++ b/hyperglass/ui/components/results/copyButton.tsx @@ -1,12 +1,9 @@ -import dynamic from 'next/dynamic'; -import { Button, Icon, Tooltip, useClipboard } from '@chakra-ui/react'; - -const Copy = dynamic(() => import('@meronex/icons/fi').then(i => i.FiCopy)); -const Check = dynamic(() => import('@meronex/icons/fi').then(i => i.FiCheck)); +import { Button, Tooltip, useClipboard } from '@chakra-ui/react'; +import { DynamicIcon } from '~/components'; import type { TCopyButton } from './types'; -export const CopyButton: React.FC = (props: TCopyButton) => { +export const CopyButton = (props: TCopyButton): JSX.Element => { const { copyValue, ...rest } = props; const { onCopy, hasCopied } = useClipboard(copyValue); return ( @@ -20,7 +17,7 @@ export const CopyButton: React.FC = (props: TCopyButton) => { colorScheme="secondary" {...rest} > - + ); diff --git a/hyperglass/ui/components/results/header.tsx b/hyperglass/ui/components/results/header.tsx index 3e3f5a8..fbe8dae 100644 --- a/hyperglass/ui/components/results/header.tsx +++ b/hyperglass/ui/components/results/header.tsx @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { AccordionIcon, Box, Spinner, HStack, Text, Tooltip } from '@chakra-ui/react'; -import { BisError as Warning } from '@meronex/icons/bi'; -import { FaCheckCircle as Check } from '@meronex/icons/fa'; +import { DynamicIcon } from '~/components'; import { useConfig, useColorValue } from '~/context'; import { useOpposingColor, useStrf } from '~/hooks'; @@ -43,8 +42,8 @@ export const ResultHeader: React.FC = (props: TResultHeader) => { {loading ? ( ) : ( - = ( - + diff --git a/hyperglass/ui/components/results/requeryButton.tsx b/hyperglass/ui/components/results/requeryButton.tsx index 1a691a2..9724659 100644 --- a/hyperglass/ui/components/results/requeryButton.tsx +++ b/hyperglass/ui/components/results/requeryButton.tsx @@ -1,11 +1,9 @@ import { forwardRef } from 'react'; -import dynamic from 'next/dynamic'; -import { Button, Icon, Tooltip } from '@chakra-ui/react'; +import { Button, Tooltip } from '@chakra-ui/react'; +import { DynamicIcon } from '~/components'; import type { TRequeryButton } from './types'; -const Repeat = dynamic(() => import('@meronex/icons/fi').then(i => i.FiRepeat)); - const _RequeryButton: React.ForwardRefRenderFunction = ( props: TRequeryButton, ref, @@ -25,7 +23,7 @@ const _RequeryButton: React.ForwardRefRenderFunction - + ); diff --git a/hyperglass/ui/components/submit/submit.tsx b/hyperglass/ui/components/submit/submit.tsx index 223c410..d5b21cd 100644 --- a/hyperglass/ui/components/submit/submit.tsx +++ b/hyperglass/ui/components/submit/submit.tsx @@ -13,9 +13,8 @@ import { ModalCloseButton, PopoverCloseButton, } from '@chakra-ui/react'; -import { FiSearch } from '@meronex/icons/fi'; import { useFormContext } from 'react-hook-form'; -import { If, ResolvedTarget } from '~/components'; +import { DynamicIcon, If, ResolvedTarget } from '~/components'; import { useMobile, useColorValue } from '~/context'; import { useFormState } from '~/hooks'; @@ -33,7 +32,7 @@ const _SubmitIcon: React.ForwardRefRenderFunction< size="lg" width={16} type="submit" - icon={} + icon={} title="Submit Query" colorScheme="primary" isLoading={isLoading} diff --git a/hyperglass/ui/components/table/main.tsx b/hyperglass/ui/components/table/main.tsx index e7d4969..461dfa7 100644 --- a/hyperglass/ui/components/table/main.tsx +++ b/hyperglass/ui/components/table/main.tsx @@ -1,11 +1,9 @@ // This rule isn't needed because react-table does this for us, for better or worse. /* eslint react/jsx-key: 0 */ - -import dynamic from 'next/dynamic'; -import { Flex, Icon, Text } from '@chakra-ui/react'; +import { Flex, Text } from '@chakra-ui/react'; import { usePagination, useSortBy, useTable } from 'react-table'; import { useMobile } from '~/context'; -import { CardBody, CardFooter, CardHeader, If } from '~/components'; +import { CardBody, CardFooter, CardHeader, DynamicIcon, If } from '~/components'; import { TableMain } from './table'; import { TableCell } from './cell'; import { TableHead } from './head'; @@ -18,25 +16,6 @@ import type { TableOptions, PluginHook } from 'react-table'; import type { TCellRender } from '~/types'; import type { TTable } from './types'; -const ChevronRight = dynamic(() => - import('@meronex/icons/fa').then(i => i.FaChevronRight), -); - -const ChevronLeft = dynamic(() => - import('@meronex/icons/fa').then(i => i.FaChevronLeft), -); - -const ChevronDown = dynamic(() => - import('@meronex/icons/fa').then(i => i.FaChevronDown), -); - -const DoubleChevronRight = dynamic(() => - import('@meronex/icons/fi').then(i => i.FiChevronsRight), -); -const DoubleChevronLeft = dynamic(() => - import('@meronex/icons/fi').then(i => i.FiChevronsLeft), -); - export const Table: React.FC = (props: TTable) => { const { data, @@ -112,10 +91,10 @@ export const Table: React.FC = (props: TTable) => { - + - + {''} @@ -163,13 +142,13 @@ export const Table: React.FC = (props: TTable) => { mr={2} onClick={() => gotoPage(0)} isDisabled={!canPreviousPage} - icon={} + icon={} /> previousPage()} isDisabled={!canPreviousPage} - icon={} + icon={} /> @@ -193,12 +172,12 @@ export const Table: React.FC = (props: TTable) => { ml={2} onClick={nextPage} isDisabled={!canNextPage} - icon={} + icon={} /> } + icon={} onClick={() => gotoPage(pageCount ? pageCount - 1 : 1)} /> diff --git a/hyperglass/ui/components/util/dynamic-icon.tsx b/hyperglass/ui/components/util/dynamic-icon.tsx new file mode 100644 index 0000000..2a91845 --- /dev/null +++ b/hyperglass/ui/components/util/dynamic-icon.tsx @@ -0,0 +1,181 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +/* eslint-disable react-hooks/rules-of-hooks */ +import { memo, useMemo } from 'react'; +import dynamic from 'next/dynamic'; +import { chakra, Icon as ChakraIcon } from '@chakra-ui/react'; +import isEqual from 'react-fast-compare'; + +import type { IconProps as ChakraIconProps, TooltipProps } from '@chakra-ui/react'; + +interface IconMap { + [library: string]: string; +} + +interface DynamicIconProps extends Omit { + icon: IconMap; +} + +interface ErrorIconProps { + message: string; +} + +interface IconErrorConstructor { + original: IconMap; + library: string; + iconName: string; +} + +/** + * Extend builtin `Error` for easier handling of icon rendering errors. + */ +class IconError extends Error { + /** + * Original family → icon mapping object. + */ + original: IconMap; + /** + * Determined family/icon library. + */ + library: string; + /** + * Determined icon name. + */ + iconName: string; + + constructor({ original, library, iconName }: IconErrorConstructor) { + super(); + this.original = original; + this.library = library; + this.iconName = iconName; + this.stack = this.stack + `\nOriginal object: '${JSON.stringify(this.original)}'`; + } + + get message(): string { + return `No icon matches 'react-icons/${this.library}/${this.iconName}'`; + } +} + +/** + * Derive `react-icons` icon family → icon name mapping with proper capitalization. Also handles + * existence (or not) of the family prefix. + * @param iconObj Family to icon name mapping. + * + * @example + * ```js + * iconPath({ fa: 'FaPlus' }); + * iconPath({ fa: 'faplus' }); + * iconPath({ fa: 'plus' }); + * // all return → ['fa', 'FaPlus'] + * ``` + * @returns + */ +function iconPath(iconObj: IconMap): [string, string] { + // Capitalize the first character of a string. + const capitalizeFirst = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + + // Get the first object key. + const [familyKey] = Object.keys(iconObj); + // Capitalize the family name. + const family = capitalizeFirst(familyKey!); + // Get the icon name. + const initialName = iconObj[familyKey!]; + // Capitalize the icon name. If `faplus` is provided, it will now be `Faplus`. + let name = capitalizeFirst(initialName!); + // Create a regex pattern to determine if the family name is in the icon name. If `name` is + // `Faplus`, this will be true. + const familyPattern = new RegExp(`^${family}`, 'g'); + + if (name.match(familyPattern)) { + // If the icon name contains the family name, remove it and capitalize the result. If `name` + // was `Faplus`, it is now `Plus`. + name = capitalizeFirst(name.replace(familyPattern, '')); + } + // Return a tuple of [family, icon name], i.e. [fa, FaPlus]. + return [family.toLowerCase(), `${family}${name}`]; +} + +/** + * Generic error icon to indicate that there was a problem dynamically importing or otherwise + * rendering the dynamic icon. Wraps generic icon in a tooltip that provides more detail. This + * is dynamically imported at render time in an effort to reduce load times. + * + * @param props Error message to be displayed. + */ +const ErrorIcon = (props: ErrorIconProps): JSX.Element => { + const Tooltip = dynamic(() => import('@chakra-ui/react').then(m => m.Tooltip)); + return ( + + + ⚠ + + + ); +}; + +const _DynamicIcon = (props: DynamicIconProps): JSX.Element => { + const { icon: iconObj, ...rest } = props; + // Create a string representation of the icon family and name mapping for memoization. + const key = Object.entries(iconObj).flat().join('--'); + try { + const [library, iconName] = useMemo(() => { + return iconPath(iconObj); + }, [key]); + + if (!library || !iconName) { + // If either the library or icon name are falsy, error out. + throw new IconError({ original: iconObj, iconName, library }); + } + // Create a memoized version of the imported component, to update only when the computed + // family/icon names are changed. Attempt to dynamically import icon from formatted + // library/icon name. + + const Component = useMemo( + () => + dynamic(() => + import(`react-icons/${library}/index.js`) + .then(i => { + if (!(iconName in i)) { + // If the icon name doesn't exist in the module, error out. + throw new IconError({ original: iconObj, iconName, library }); + } + // Otherwise, return the imported icon. + return i[iconName as keyof typeof i]; + }) + .catch(error => { + // Handle any error that occurs during dynamic import. + console.error(error); + const CaughtError = (): JSX.Element => ; + return CaughtError; + }), + ), + [library, iconName], + ); + + // Return a Chakra-UI icon instance with the imported icon. + return ; + } catch (error) { + // Handle any other uncaught errors. + console.error(error); + return ; + } +}; + +/** + * Dynamically import a `react-icons` icon by name and wrap it in a Chakra-UI icon component. + * + * @param props Icon family to icon name mapping. + * + * @throws An error icon is produced if there is any error during the dynamic import process. A + * console message is also displayed with additional details. + * + * @example + * ```js + * <> + * + * // This also works: + * + * + * ``` + */ +export const DynamicIcon = memo(_DynamicIcon, isEqual); +export default DynamicIcon; diff --git a/hyperglass/ui/components/util/index.ts b/hyperglass/ui/components/util/index.ts index d7a1522..56b40ca 100644 --- a/hyperglass/ui/components/util/index.ts +++ b/hyperglass/ui/components/util/index.ts @@ -1,2 +1,3 @@ export * from './animated'; +export * from './dynamic-icon'; export * from './if'; diff --git a/hyperglass/ui/hooks/useFormState.ts b/hyperglass/ui/hooks/useFormState.ts index c163f62..432df4f 100644 --- a/hyperglass/ui/hooks/useFormState.ts +++ b/hyperglass/ui/hooks/useFormState.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import create from 'zustand'; -import { intersectionWith } from 'lodash'; +import intersectionWith from 'lodash/intersectionWith'; import plur from 'plur'; import isEqual from 'react-fast-compare'; import { all, andJoin, dedupObjectArray, withDev } from '~/util'; diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index 6c1ee90..0dd075a 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -21,7 +21,6 @@ "@emotion/styled": "^11.6.0", "@hookform/devtools": "^4.0.1", "@hookform/resolvers": "^2.8.4", - "@meronex/icons": "^4.0.0", "dagre": "^0.8.5", "dayjs": "^1.10.4", "framer-motion": "^5.4.1", @@ -38,6 +37,7 @@ "react-flow-renderer": "^9.6.0", "react-ga": "^3.3.0", "react-hook-form": "^7.21.0", + "react-icons": "^4.3.1", "react-markdown": "^5.0.3", "react-query": "^3.16.0", "react-select": "^5.2.1", @@ -45,13 +45,14 @@ "remark-gfm": "^1.0.0", "string-format": "^2.0.0", "vest": "^3.2.8", - "zustand": "^3.5.10" + "zustand": "^3.6.6" }, "devDependencies": { "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.1.0", "@types/dagre": "^0.7.44", "@types/express": "^4.17.13", + "@types/lodash": "^4.14.177", "@types/node": "^14.14.41", "@types/react": "^17.0.3", "@types/react-table": "^7.7.1", diff --git a/hyperglass/ui/util/state.ts b/hyperglass/ui/util/state.ts index 40a70b9..3fac87d 100644 --- a/hyperglass/ui/util/state.ts +++ b/hyperglass/ui/util/state.ts @@ -1,6 +1,6 @@ import { devtools } from 'zustand/middleware'; -import type { StateCreator } from 'zustand'; +import type { StateCreator, SetState, GetState, StoreApi } from 'zustand'; /** * Wrap a zustand state function with devtools, if applicable. @@ -14,7 +14,7 @@ export function withDev( name: string, ): StateCreator { if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') { - return devtools(store, { name }); + return devtools, GetState, StoreApi>(store, { name }); } return store; } diff --git a/hyperglass/ui/yarn.lock b/hyperglass/ui/yarn.lock index ddbe287..e7a284b 100644 --- a/hyperglass/ui/yarn.lock +++ b/hyperglass/ui/yarn.lock @@ -1514,14 +1514,6 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" -"@meronex/icons@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@meronex/icons/-/icons-4.0.0.tgz#26e089a8a4ec176a5b6778fd54fcdd25b4746c67" - integrity sha512-WnoxUT02qawZSvsoPSwe7YOqOk0APysIHugiD3dYdc/QNeoigN4PD8mmmtmZFKlv8/Z7eERub0BmPkWcJ1BI+w== - dependencies: - camelcase "^5.0.0" - ncp "^2.0.0" - "@napi-rs/triples@1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.0.3.tgz#76d6d0c3f4d16013c61e45dfca5ff1e6c31ae53c" @@ -2094,6 +2086,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6" integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q== +"@types/lodash@^4.14.177": + version "4.14.177" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.177.tgz#f70c0d19c30fab101cad46b52be60363c43c4578" + integrity sha512-0fDwydE2clKe9MNfvXHBHF9WEahRuj+msTuQqOmAApNORFvhMYZKNGGJdCzuhheVjMps/ti0Ak/iJPACMaevvw== + "@types/mdast@^3.0.0", "@types/mdast@^3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.3.tgz#2d7d671b1cd1ea3deb306ea75036c2a0407d2deb" @@ -2944,7 +2941,7 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase@^5.0.0, camelcase@^5.3.1: +camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== @@ -6175,11 +6172,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -ncp@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" - integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= - negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -6957,6 +6949,11 @@ react-hook-form@^7.21.0: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.21.0.tgz#f6f0311295b83ca768873ca9f2a787ff1525f591" integrity sha512-aekCf+dedYFIg+7nCK2acMvZ+s6Ohw2I7UNQ+zNIadBl1SoXow2Tl6c3F49xF8GFCdn5jeK43JHH26rmtdRyLQ== +react-icons@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-4.3.1.tgz#2fa92aebbbc71f43d2db2ed1aed07361124e91ca" + integrity sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ== + react-is@17.0.2, "react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -8433,10 +8430,10 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zustand@^3.5.10: - version "3.5.10" - resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.5.10.tgz#d2622efd64530ffda285ee5b13ff645b68ab0faf" - integrity sha512-upluvSRWrlCiExu2UbkuMIPJ9AigyjRFoO7O9eUossIj7rPPq7pcJ0NKk6t2P7KF80tg/UdPX6/pNKOSbs9DEg== +zustand@^3.6.6: + version "3.6.6" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.6.6.tgz#3b7473a15813f7af9784233abd052c3b4560bbcc" + integrity sha512-y4755cIzJHQFEHgTQ5cHrlHdmXMxm5N3DU05Q27yT6rK4lKs2336t5IsAz5q9/GRaoEz6o8SiCOPDhZd5BnneA== zwitch@^1.0.0: version "1.0.5"