From 85566b81ab143bc86815f8404ce9d7a493ee5ea3 Mon Sep 17 00:00:00 2001 From: thatmattlove Date: Tue, 21 Sep 2021 08:20:44 -0700 Subject: [PATCH] =?UTF-8?q?UI=20improvements,=20hookstate=20=E2=86=92=20zu?= =?UTF-8?q?stand=20migration,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hyperglass/ui/components/debugger.tsx | 18 +- hyperglass/ui/components/form/field.tsx | 23 +- hyperglass/ui/components/form/index.ts | 1 - hyperglass/ui/components/form/queryGroup.tsx | 40 --- .../ui/components/form/queryLocation.tsx | 221 +++++++++++++--- hyperglass/ui/components/form/queryTarget.tsx | 22 +- hyperglass/ui/components/form/queryType.tsx | 169 ++++++++++-- .../ui/components/form/resolvedTarget.tsx | 70 ++--- hyperglass/ui/components/form/types.ts | 16 +- .../ui/components/greeting/greeting.tsx | 27 +- hyperglass/ui/components/header/header.tsx | 8 +- hyperglass/ui/components/header/title.tsx | 29 +-- hyperglass/ui/components/header/titleOnly.tsx | 7 +- hyperglass/ui/components/layout/frame.tsx | 16 +- hyperglass/ui/components/layout/layout.tsx | 9 +- .../ui/components/layout/resetButton.tsx | 8 +- hyperglass/ui/components/lookingGlass.tsx | 243 +++++------------- hyperglass/ui/components/path/path.tsx | 8 +- hyperglass/ui/components/results/group.tsx | 23 +- .../ui/components/results/individual.tsx | 60 +++-- hyperglass/ui/components/results/tags.tsx | 67 ++--- hyperglass/ui/components/results/types.ts | 23 +- .../ui/components/results/useResults.ts | 100 ------- hyperglass/ui/components/select/index.ts | 1 + hyperglass/ui/components/select/option.tsx | 25 ++ hyperglass/ui/components/select/select.tsx | 10 +- hyperglass/ui/components/select/types.ts | 8 +- hyperglass/ui/components/submit/submit.tsx | 41 +-- hyperglass/ui/components/submit/types.ts | 8 +- hyperglass/ui/components/util/animated.tsx | 25 +- hyperglass/ui/context/types.ts | 14 +- hyperglass/ui/hooks/index.ts | 2 +- hyperglass/ui/hooks/types.ts | 69 ++--- hyperglass/ui/hooks/useDirective.ts | 17 +- hyperglass/ui/hooks/useFormState.ts | 235 +++++++++++++++++ hyperglass/ui/hooks/useGoogleAnalytics.tsx | 28 +- hyperglass/ui/hooks/useGreeting.ts | 89 ++++--- hyperglass/ui/hooks/useLGQuery.ts | 12 +- hyperglass/ui/hooks/useLGState.ts | 187 -------------- hyperglass/ui/package.json | 8 +- hyperglass/ui/pages/_app.tsx | 4 - hyperglass/ui/types/common.ts | 23 +- hyperglass/ui/types/data.ts | 1 - hyperglass/ui/types/guards.ts | 20 -- hyperglass/ui/util/common.ts | 50 ++++ hyperglass/ui/util/index.ts | 1 + hyperglass/ui/util/state.ts | 20 ++ hyperglass/ui/yarn.lock | 65 +++-- 48 files changed, 1133 insertions(+), 1038 deletions(-) delete mode 100644 hyperglass/ui/components/form/queryGroup.tsx delete mode 100644 hyperglass/ui/components/results/useResults.ts create mode 100644 hyperglass/ui/components/select/option.tsx create mode 100644 hyperglass/ui/hooks/useFormState.ts delete mode 100644 hyperglass/ui/hooks/useLGState.ts create mode 100644 hyperglass/ui/util/state.ts diff --git a/hyperglass/ui/components/debugger.tsx b/hyperglass/ui/components/debugger.tsx index dcc3068..9255ece 100644 --- a/hyperglass/ui/components/debugger.tsx +++ b/hyperglass/ui/components/debugger.tsx @@ -12,8 +12,13 @@ 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 { useHyperglassConfig } from '~/hooks'; + import type { UseDisclosureReturn } from '@chakra-ui/react'; interface TViewer extends Pick { @@ -50,6 +55,7 @@ export const Debugger: React.FC = () => { useBreakpointValue({ base: 'SMALL', md: 'MEDIUM', lg: 'LARGE', xl: 'X-LARGE' }) ?? 'UNKNOWN'; const tagSize = useBreakpointValue({ base: 'sm', lg: 'lg' }) ?? 'lg'; const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }) ?? 'sm'; + const { refetch } = useHyperglassConfig(); return ( <> { {colorMode.toUpperCase()} - - + {mediaSize} diff --git a/hyperglass/ui/components/form/field.tsx b/hyperglass/ui/components/form/field.tsx index 60392b7..81b9920 100644 --- a/hyperglass/ui/components/form/field.tsx +++ b/hyperglass/ui/components/form/field.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useMemo } from 'react'; import { Flex, FormControl, FormLabel, FormErrorMessage } from '@chakra-ui/react'; import { useFormContext } from 'react-hook-form'; import { If } from '~/components'; @@ -6,6 +6,7 @@ import { useColorValue } from '~/context'; import { useBooleanValue } from '~/hooks'; import type { FieldError } from 'react-hook-form'; +import type { FormData } from '~/types'; import type { TField } from './types'; export const FormField: React.FC = (props: TField) => { @@ -14,19 +15,17 @@ export const FormField: React.FC = (props: TField) => { const errorColor = useColorValue('red.500', 'red.300'); const opacity = useBooleanValue(hiddenLabels, 0, undefined); - const [error, setError] = useState>(null); + const { formState } = useFormContext(); - const { - formState: { errors }, - } = useFormContext(); - - useEffect(() => { - if (name in errors) { - console.dir(errors); - setError(errors[name]); - console.warn(`Error on field '${label}': ${error?.message}`); + const error = useMemo(() => { + if (name in formState.errors) { + console.group(`Error on field '${label}'`); + console.warn(formState.errors[name as keyof FormData]); + console.groupEnd(); + return formState.errors[name as keyof FormData] as FieldError; } - }, [error, errors, label, name, setError]); + return null; + }, [formState, label, name]); return ( = (props: TQueryGroup) => { - const { onChange, label } = props; - const { selections, availableGroups, queryLocation } = useLGState(); - const { exportState } = useLGMethods(); - - const options = useMemo( - () => availableGroups.map(g => ({ label: g.value, value: g.value })), - // eslint-disable-next-line react-hooks/exhaustive-deps - [availableGroups, queryLocation], - ); - - function handleChange(e: TSelectOption | TSelectOption[]): void { - let value = ''; - if (!Array.isArray(e) && e !== null) { - selections.queryGroup.set(e); - value = e.value; - } else { - selections.queryGroup.set(null); - } - onChange({ field: 'queryGroup', value }); - } - - return ( - - ); + /** + * Update form and state when select element values change. + * + * @param options Final value. React-select determines if an option is being added or removed and + * only sends back the final value. + */ + function handleSelectChange(options: SingleOption[] | SingleOption): void { + if (Array.isArray(options)) { + onChange({ field: 'queryLocation', value: options.map(o => o.value) }); + setSelection('queryLocation', options); + } else { + onChange({ field: 'queryLocation', value: options.value }); + setSelection('queryLocation', [options]); + } + } + + if (element === 'cards') { + return ( + + {options.map(group => ( + + + {group.label} + + {group.options.map(opt => { + return ( + + ); + })} + + ))} + + ); + } else if (element === 'select') { + return ( + + + + + ); +}; + +const MenuList = (props: MenuListComponentProps) => { + const { children, ...rest } = props; + const filtered = useFormState(s => s.filtered); + const selected = useFilter(state => state.selected); + const setSelected = useFilter(state => state.setSelected); + + const { getRadioProps, getRootProps } = useRadioGroup({ + name: 'queryGroup', + value: selected, + }); + + function handleClick(value: string): void { + setSelected(value); + } + return ( + + + handleClick('') })}> + None + + {filtered.groups.map(value => { + return ( + handleClick(value) })} + > + {value} + + ); + })} + + {children} + + ); +}; + +export const QueryType = (props: TQuerySelectField): JSX.Element => { const { onChange, label } = props; const { formState: { errors }, } = useFormContext(); - const { selections, availableTypes, queryType } = useLGState(); - const { exportState } = useLGMethods(); + const setSelection = useFormState(s => s.setSelection); + const selections = useFormState(s => s.selections); + const setFormValue = useFormState(s => s.setFormValue); + const options = useOptions(); + const { filter } = useFilter(); // Intentionally re-render on any changes - const options = useMemo( - () => availableTypes.map(t => ({ label: t.name.value, value: t.id.value })), - [availableTypes], - ); - - function handleChange(e: TSelectOption | TSelectOption[]): void { + function handleChange(e: SingleOption | SingleOption[]): void { let value = ''; if (!Array.isArray(e) && e !== null) { - selections.queryType.set(e); + // setFormValue('queryType', e.value); + setSelection('queryType', e); value = e.value; } else { - selections.queryType.set(null); - queryType.set(''); + setFormValue('queryType', ''); + setSelection('queryType', null); } onChange({ field: 'queryType', value }); } @@ -37,9 +174,11 @@ export const QueryType: React.FC = (props: TQuerySelectField) name="queryType" options={options} aria-label={label} + filterOption={filter} onChange={handleChange} - value={exportState(selections.queryType.value)} + components={{ MenuList }} isError={'queryType' in errors} + value={selections.queryType} /> ); }; diff --git a/hyperglass/ui/components/form/resolvedTarget.tsx b/hyperglass/ui/components/form/resolvedTarget.tsx index 5a65d8e..030ce77 100644 --- a/hyperglass/ui/components/form/resolvedTarget.tsx +++ b/hyperglass/ui/components/form/resolvedTarget.tsx @@ -1,8 +1,11 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import dynamic from 'next/dynamic'; import { Button, chakra, Stack, Text, VStack } from '@chakra-ui/react'; import { useConfig, useColorValue } from '~/context'; -import { useStrf, useLGState, useDNSQuery } from '~/hooks'; +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)), @@ -12,9 +15,6 @@ const LeftArrow = chakra( dynamic(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleLeft)), ); -import type { DnsOverHttps } from '~/types'; -import type { TResolvedTarget } from './types'; - function findAnswer(data: DnsOverHttps.Response | undefined): string { let answer = ''; if (typeof data !== 'undefined') { @@ -24,18 +24,18 @@ function findAnswer(data: DnsOverHttps.Response | undefined): string { return answer; } -export const ResolvedTarget: React.FC = (props: TResolvedTarget) => { - const { setTarget, errorClose } = props; +export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => { + const { errorClose } = props; const strF = useStrf(); const { web } = useConfig(); - const { displayTarget, isSubmitting, families, queryTarget } = useLGState(); + + const setStatus = useFormState(s => s.setStatus); + const displayTarget = useFormState(s => s.target.display); + const setFormValue = useFormState(s => s.setFormValue); const color = useColorValue('secondary.500', 'secondary.300'); const errorColor = useColorValue('red.500', 'red.300'); - const query4 = Array.from(families.value).includes(4); - const query6 = Array.from(families.value).includes(6); - const tooltip4 = strF(web.text.fqdnTooltip, { protocol: 'IPv4' }); const tooltip6 = strF(web.text.fqdnTooltip, { protocol: 'IPv6' }); @@ -47,14 +47,14 @@ export const ResolvedTarget: React.FC = (props: TResolvedTarget isLoading: isLoading4, isError: isError4, error: error4, - } = useDNSQuery(displayTarget.value, 4); + } = useDNSQuery(displayTarget, 4); const { data: data6, isLoading: isLoading6, isError: isError6, error: error6, - } = useDNSQuery(displayTarget.value, 6); + } = useDNSQuery(displayTarget, 6); isError4 && console.error(error4); isError6 && console.error(error6); @@ -62,39 +62,38 @@ export const ResolvedTarget: React.FC = (props: TResolvedTarget const answer4 = useMemo(() => findAnswer(data4), [data4]); const answer6 = useMemo(() => findAnswer(data6), [data6]); - const handleOverride = useCallback( - (value: string): void => setTarget({ field: 'queryTarget', value }), - [setTarget], - ); - function selectTarget(value: string): void { - queryTarget.set(value); - isSubmitting.set(true); + setFormValue('queryTarget', value); + setStatus('results'); } - useEffect(() => { - if (query6 && data6?.Answer) { - handleOverride(findAnswer(data6)); - } else if (query4 && data4?.Answer && !query6 && !data6?.Answer) { - handleOverride(findAnswer(data4)); - } else if (query4 && data4?.Answer) { - handleOverride(findAnswer(data4)); - } - }, [data4, data6, handleOverride, query4, query6]); + const hasAnswer = useMemo( + () => (!isError4 || !isError6) && (answer4 !== '' || answer6 !== ''), + [answer4, answer6, isError4, isError6], + ); + const showA = useMemo( + () => !isLoading4 && !isError4 && answer4 !== '', + [isLoading4, isError4, answer4], + ); + + const showAAAA = useMemo( + () => !isLoading6 && !isError6 && answer6 !== '', + [isLoading6, isError6, answer6], + ); return ( - {(answer4 || answer6) && ( + {hasAnswer && ( {messageStart} - {`${displayTarget.value}`.toLowerCase()} + {`${displayTarget}`.toLowerCase()} {messageEnd} )} - {!isLoading4 && !isError4 && query4 && answer4 && ( + {showA && ( )} - {!isLoading6 && !isError6 && query6 && answer6 && ( + {showAAAA && ( )} - {!answer4 && !answer6 && ( + {!hasAnswer && ( <> {errorStart} - {`${displayTarget.value}`.toLowerCase()} + {`${displayTarget}`.toLowerCase()} {errorEnd} diff --git a/hyperglass/ui/components/header/header.tsx b/hyperglass/ui/components/header/header.tsx index 07aba3b..ea7569f 100644 --- a/hyperglass/ui/components/header/header.tsx +++ b/hyperglass/ui/components/header/header.tsx @@ -2,7 +2,7 @@ import { useRef } from 'react'; import { Flex, ScaleFade } from '@chakra-ui/react'; import { AnimatedDiv } from '~/components'; import { useBreakpointValue } from '~/context'; -import { useBooleanValue, useLGState } from '~/hooks'; +import { useBooleanValue, useFormState } from '~/hooks'; import { Title } from './title'; import type { THeader } from './types'; @@ -10,12 +10,12 @@ import type { THeader } from './types'; export const Header: React.FC = (props: THeader) => { const { resetForm, ...rest } = props; - const { isSubmitting } = useLGState(); + const status = useFormState(s => s.status); const titleRef = useRef({} as HTMLDivElement); const titleWidth = useBooleanValue( - isSubmitting.value, + status === 'results', { base: '75%', lg: '50%' }, { base: '75%', lg: '75%' }, ); @@ -33,7 +33,7 @@ export const Header: React.FC = (props: THeader) => { maxW={titleWidth} // This is here for the logo justifyContent={justify} - mx={{ base: isSubmitting.value ? 'auto' : 0, lg: 'auto' }} + mx={{ base: status === 'results' ? 'auto' : 0, lg: 'auto' }} > </AnimatedDiv> diff --git a/hyperglass/ui/components/header/title.tsx b/hyperglass/ui/components/header/title.tsx index 203f839..944229c 100644 --- a/hyperglass/ui/components/header/title.tsx +++ b/hyperglass/ui/components/header/title.tsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import { isSafari } from 'react-device-detect'; import { If } from '~/components'; import { useConfig, useMobile } from '~/context'; -import { useBooleanValue, useLGState, useLGMethods } from '~/hooks'; +import { useBooleanValue, useFormState } from '~/hooks'; import { SubtitleOnly } from './subtitleOnly'; import { TitleOnly } from './titleOnly'; import { Logo } from './logo'; @@ -16,12 +16,12 @@ const AnimatedVStack = motion(VStack); * Title wrapper for mobile devices, breakpoints sm & md. */ const MWrapper: React.FC<TMWrapper> = (props: TMWrapper) => { - const { isSubmitting } = useLGState(); + const status = useFormState(s => s.status); return ( <AnimatedVStack layout spacing={1} - alignItems={isSubmitting.value ? 'center' : 'flex-start'} + alignItems={status === 'results' ? 'center' : 'flex-start'} {...props} /> ); @@ -31,15 +31,15 @@ const MWrapper: React.FC<TMWrapper> = (props: TMWrapper) => { * Title wrapper for desktop devices, breakpoints lg & xl. */ const DWrapper: React.FC<TDWrapper> = (props: TDWrapper) => { - const { isSubmitting } = useLGState(); + const status = useFormState(s => s.status); return ( <AnimatedVStack spacing={1} initial="main" alignItems="center" - animate={isSubmitting.value ? 'submitting' : 'main'} + animate={status} transition={{ damping: 15, type: 'spring', stiffness: 100 }} - variants={{ submitting: { scale: 0.5 }, main: { scale: 1 } }} + variants={{ results: { scale: 0.5 }, form: { scale: 1 } }} {...props} /> ); @@ -108,15 +108,12 @@ export const Title: React.FC<TTitle> = (props: TTitle) => { const { web } = useConfig(); const { titleMode } = web.text; - const { isSubmitting } = useLGState(); - const { resetForm } = useLGMethods(); + const { status, reset } = useFormState(({ status, reset }) => ({ + status, + reset, + })); - const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' }); - - function handleClick(): void { - isSubmitting.set(false); - resetForm(); - } + const titleHeight = useBooleanValue(status === 'results', undefined, { md: '20vh' }); return ( <Flex @@ -132,7 +129,7 @@ export const Title: React.FC<TTitle> = (props: TTitle) => { div up to the parent's max-width. The fix is to hard-code a flex-basis width. */ flexBasis={{ base: '100%', lg: isSafari ? '33%' : '100%' }} - mt={[null, isSubmitting.value ? null : 'auto']} + mt={{ md: status === 'results' ? undefined : 'auto' }} {...rest} > <Button @@ -140,7 +137,7 @@ export const Title: React.FC<TTitle> = (props: TTitle) => { variant="link" flexWrap="wrap" flexDir="column" - onClick={handleClick} + onClick={() => reset()} _focus={{ boxShadow: 'none' }} _hover={{ textDecoration: 'none' }} > diff --git a/hyperglass/ui/components/header/titleOnly.tsx b/hyperglass/ui/components/header/titleOnly.tsx index c263999..625ecd2 100644 --- a/hyperglass/ui/components/header/titleOnly.tsx +++ b/hyperglass/ui/components/header/titleOnly.tsx @@ -1,13 +1,12 @@ import { Heading } from '@chakra-ui/react'; import { useConfig } from '~/context'; -import { useBooleanValue, useLGState } from '~/hooks'; +import { useBooleanValue, useFormState } from '~/hooks'; import { useTitleSize } from './useTitleSize'; export const TitleOnly: React.FC = () => { const { web } = useConfig(); - const { isSubmitting } = useLGState(); - - const margin = useBooleanValue(isSubmitting.value, 0, 2); + const status = useFormState(s => s.status); + const margin = useBooleanValue(status === 'results', 0, 2); const sizeSm = useTitleSize(web.text.title, '2xl', []); return ( diff --git a/hyperglass/ui/components/layout/frame.tsx b/hyperglass/ui/components/layout/frame.tsx index e73fd3a..17e66f3 100644 --- a/hyperglass/ui/components/layout/frame.tsx +++ b/hyperglass/ui/components/layout/frame.tsx @@ -1,10 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useRouter } from 'next/router'; import { Flex } from '@chakra-ui/react'; import { isSafari } from 'react-device-detect'; import { If, Debugger, Greeting, Footer, Header } from '~/components'; import { useConfig } from '~/context'; -import { useLGState, useLGMethods, useGoogleAnalytics } from '~/hooks'; +import { useGoogleAnalytics, useFormState } from '~/hooks'; import { ResetButton } from './resetButton'; import type { TFrame } from './types'; @@ -12,16 +12,18 @@ import type { TFrame } from './types'; export const Frame = (props: TFrame): JSX.Element => { const router = useRouter(); const { developerMode, googleAnalytics } = useConfig(); - const { isSubmitting } = useLGState(); - const { resetForm } = useLGMethods(); + const { setStatus, reset } = useFormState( + useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []), + ); + const { initialize, trackPage } = useGoogleAnalytics(); const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement); function handleReset(): void { containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - isSubmitting.set(false); - resetForm(); + setStatus('form'); + reset(); } useEffect(() => { @@ -48,7 +50,7 @@ export const Frame = (props: TFrame): JSX.Element => { */ minHeight={isSafari ? '-webkit-fill-available' : '100vh'} > - <Header resetForm={handleReset} /> + <Header resetForm={() => handleReset()} /> <Flex px={4} py={0} diff --git a/hyperglass/ui/components/layout/layout.tsx b/hyperglass/ui/components/layout/layout.tsx index afd92bf..d01b002 100644 --- a/hyperglass/ui/components/layout/layout.tsx +++ b/hyperglass/ui/components/layout/layout.tsx @@ -1,14 +1,13 @@ import { AnimatePresence } from 'framer-motion'; import { LookingGlass, Results } from '~/components'; -import { useLGMethods } from '~/hooks'; +import { useView } from '~/hooks'; import { Frame } from './frame'; -export const Layout: React.FC = () => { - const { formReady } = useLGMethods(); - const ready = formReady(); +export const Layout = (): JSX.Element => { + const view = useView(); return ( <Frame> - {ready ? ( + {view === 'results' ? ( <Results /> ) : ( <AnimatePresence> diff --git a/hyperglass/ui/components/layout/resetButton.tsx b/hyperglass/ui/components/layout/resetButton.tsx index 145ea61..ce1eee7 100644 --- a/hyperglass/ui/components/layout/resetButton.tsx +++ b/hyperglass/ui/components/layout/resetButton.tsx @@ -3,20 +3,20 @@ import { Flex, Icon, IconButton } from '@chakra-ui/react'; import { AnimatePresence } from 'framer-motion'; import { AnimatedDiv } from '~/components'; import { useColorValue } from '~/context'; -import { useLGState, useOpposingColor } from '~/hooks'; +import { useOpposingColor, useFormState } from '~/hooks'; import type { TResetButton } from './types'; const LeftArrow = dynamic<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaAngleLeft)); -export const ResetButton: React.FC<TResetButton> = (props: TResetButton) => { +export const ResetButton = (props: TResetButton): JSX.Element => { const { developerMode, resetForm, ...rest } = props; - const { isSubmitting } = useLGState(); + const status = useFormState(s => s.status); const bg = useColorValue('primary.500', 'primary.300'); const color = useOpposingColor(bg); return ( <AnimatePresence> - {isSubmitting.value && ( + {status === 'results' && ( <AnimatedDiv bg={bg} left={0} diff --git a/hyperglass/ui/components/lookingGlass.tsx b/hyperglass/ui/components/lookingGlass.tsx index 5d7db65..6047ec5 100644 --- a/hyperglass/ui/components/lookingGlass.tsx +++ b/hyperglass/ui/components/lookingGlass.tsx @@ -1,14 +1,11 @@ import { useCallback, useEffect, useMemo } from 'react'; +import isEqual from 'react-fast-compare'; import { Flex, ScaleFade, SlideFade } from '@chakra-ui/react'; import { FormProvider, useForm } from 'react-hook-form'; -import { intersectionWith } from 'lodash'; -import isEqual from 'react-fast-compare'; import { vestResolver } from '@hookform/resolvers/vest'; import vest, { test, enforce } from 'vest'; import { - If, FormRow, - QueryGroup, FormField, HelpModal, QueryType, @@ -18,8 +15,7 @@ import { QueryLocation, } from '~/components'; import { useConfig } from '~/context'; -import { useStrf, useGreeting, useDevice, useLGState, useLGMethods } from '~/hooks'; -import { dedupObjectArray } from '~/util'; +import { useStrf, useGreeting, useDevice, useFormState } from '~/hooks'; import { isString, isQueryField, Directive } from '~/types'; import type { FormData, OnChangeArgs } from '~/types'; @@ -36,39 +32,32 @@ const fqdnPattern = new RegExp( /^(?!:\/\/)([a-zA-Z0-9-]+\.)?[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z-]{2,6}?$/im, ); -function useIsFqdn(target: string, _type: string) { - return useCallback( - (): boolean => ['bgp_route', 'ping', 'traceroute'].includes(_type) && fqdnPattern.test(target), - [target, _type], - ); -} - -export const LookingGlass: React.FC = () => { +export const LookingGlass = (): JSX.Element => { const { web, messages } = useConfig(); - const { ack, greetingReady } = useGreeting(); + const greetingReady = useGreeting(s => s.greetingReady); + const getDevice = useDevice(); const strF = useStrf(); + const setLoading = useFormState(s => s.setLoading); + const setStatus = useFormState(s => s.setStatus); + const locationChange = useFormState(s => s.locationChange); + const setTarget = useFormState(s => s.setTarget); + const setFormValue = useFormState(s => s.setFormValue); + const { form, filtered } = useFormState( + useCallback(({ form, filtered }) => ({ form, filtered }), []), + isEqual, + ); + + const getDirective = useFormState(useCallback(s => s.getDirective, [])); + const resolvedOpen = useFormState(useCallback(s => s.resolvedOpen, [])); + const resetForm = useFormState(useCallback(s => s.reset, [])); const noQueryType = strF(messages.noInput, { field: web.text.queryType }); const noQueryLoc = strF(messages.noInput, { field: web.text.queryLocation }); const noQueryTarget = strF(messages.noInput, { field: web.text.queryTarget }); - const { - availableGroups, - queryType, - directive, - availableTypes, - btnLoading, - queryGroup, - queryTarget, - isSubmitting, - queryLocation, - displayTarget, - selections, - } = useLGState(); - - const queryTypes = useMemo(() => availableTypes.map(t => t.id.value), [availableTypes]); + const queryTypes = useMemo(() => filtered.types.map(t => t.id), [filtered.types]); const formSchema = vest.create((data: FormData = {} as FormData) => { test('queryLocation', noQueryLoc, () => { @@ -80,9 +69,6 @@ export const LookingGlass: React.FC = () => { test('queryType', noQueryType, () => { enforce(data.queryType).inside(queryTypes); }); - test('queryGroup', 'Query Group is empty', () => { - enforce(data.queryGroup).isString(); - }); }); const formInstance = useForm<FormData>({ @@ -91,155 +77,65 @@ export const LookingGlass: React.FC = () => { queryTarget: '', queryLocation: [], queryType: '', - queryGroup: '', }, }); const { handleSubmit, register, setValue, setError, clearErrors } = formInstance; - const { resolvedOpen, resetForm, getDirective } = useLGMethods(); + // const isFqdnQuery = useIsFqdn(form.queryTarget, form.queryType); + const isFqdnQuery = useCallback( + (target: string, fieldType: Directive['fieldType'] | null): boolean => + fieldType === 'text' && fqdnPattern.test(target), + [], + ); - const isFqdnQuery = useIsFqdn(queryTarget.value, queryType.value); - - const selectedDirective = useMemo(() => { - if (queryType.value === '') { - return null; - } - const directive = getDirective(queryType.value); - if (directive !== null) { - return directive; - } - return null; + const directive = useMemo<Directive | null>( + () => getDirective(), // eslint-disable-next-line react-hooks/exhaustive-deps - }, [queryType.value, queryGroup.value, getDirective]); + [form.queryType, form.queryLocation, getDirective], + ); - function submitHandler() { + function submitHandler(): void { console.table({ - 'Query Location': queryLocation.value, - 'Query Type': queryType.value, - 'Query Group': queryGroup.value, - 'Query Target': queryTarget.value, - 'Selected Directive': selectedDirective?.name ?? null, + 'Query Location': form.queryLocation.toString(), + 'Query Type': form.queryType, + 'Query Target': form.queryTarget, + 'Selected Directive': directive?.name ?? null, }); - /** - * Before submitting a query, make sure the greeting is acknowledged if required. This should - * be handled before loading the app, but people be sneaky. - */ - if (!greetingReady()) { + // Before submitting a query, make sure the greeting is acknowledged if required. This should + // be handled before loading the app, but people be sneaky. + + if (!greetingReady) { resetForm(); location.reload(); + return; } // Determine if queryTarget is an FQDN. - const isFqdn = isFqdnQuery(); + const isFqdn = isFqdnQuery(form.queryTarget, directive?.fieldType ?? null); - if (greetingReady() && !isFqdn) { - return isSubmitting.set(true); + if (greetingReady && !isFqdn) { + return setStatus('results'); } - if (greetingReady() && isFqdn) { - btnLoading.set(true); + if (greetingReady && isFqdn) { + setLoading(true); return resolvedOpen(); } else { console.group('%cSomething went wrong', 'color:red;'); console.table({ 'Greeting Required': web.greeting.required, - 'Greeting Ready': greetingReady(), - 'Greeting Acknowledged': ack.value, - 'Query Target': queryTarget.value, - 'Query Type': queryType.value, + 'Greeting Ready': greetingReady, + 'Query Target': form.queryTarget, + 'Query Type': form.queryType, 'Is FQDN': isFqdn, }); console.groupEnd(); } } - function handleLocChange(locations: string[]): void { - clearErrors('queryLocation'); - const locationNames = [] as string[]; - const allGroups = [] as string[][]; - const allTypes = [] as Directive[][]; - const allDevices = []; - - queryLocation.set(locations); - - // Create an array of each device's VRFs. - for (const loc of locations) { - const device = getDevice(loc); - locationNames.push(device.name); - allDevices.push(device); - const groups = new Set<string>(); - for (const directive of device.directives) { - for (const group of directive.groups) { - groups.add(group); - } - } - allGroups.push(Array.from(groups)); - } - - const intersecting = intersectionWith(...allGroups, isEqual); - - if (!intersecting.includes(queryGroup.value)) { - queryGroup.set(''); - queryType.set(''); - directive.set(null); - selections.merge({ queryGroup: null, queryType: null }); - } - - for (const group of intersecting) { - for (const device of allDevices) { - for (const directive of device.directives) { - if (directive.groups.includes(group)) { - // allTypes.add(directive.name); - allTypes.push(device.directives); - // allTypes.push(device.directives.map(d => d.name)); - } - } - } - } - - const intersectingTypes = intersectionWith(...allTypes, isEqual); - - availableGroups.set(intersecting); - availableTypes.set(intersectingTypes); - - // If there is more than one location selected, but there are no intersecting VRFs, show an error. - if (locations.length > 1 && intersecting.length === 0) { - setError('queryLocation', { - // message: `${locationNames.join(', ')} have no VRFs in common.`, - message: `${locationNames.join(', ')} have no groups in common.`, - }); - } - // If there is only one intersecting VRF, set it as the form value so the user doesn't have to. - else if (intersecting.length === 1) { - queryGroup.set(intersecting[0]); - } - if (availableGroups.length > 1 && intersectingTypes.length === 0) { - setError('queryLocation', { - message: `${locationNames.join(', ')} have no query types in common.`, - }); - } else if (intersectingTypes.length === 1) { - queryType.set(intersectingTypes[0].id); - } - } - - function handleGroupChange(group: string): void { - queryGroup.set(group); - let availTypes = new Array<Directive>(); - for (const loc of queryLocation) { - const device = getDevice(loc.value); - for (const directive of device.directives) { - if (directive.groups.includes(group)) { - availTypes.push(directive); - } - } - } - availTypes = dedupObjectArray<Directive>(availTypes, 'id'); - availableTypes.set(availTypes); - if (availableTypes.length === 1) { - queryType.set(availableTypes[0].name.value); - } - } + const handleLocChange = (locations: string[]) => + locationChange(locations, { setError, clearErrors, getDevice, text: web.text }); function handleChange(e: OnChangeArgs): void { // Signal the field & value to react-hook-form. @@ -252,27 +148,23 @@ export const LookingGlass: React.FC = () => { if (e.field === 'queryLocation' && Array.isArray(e.value)) { handleLocChange(e.value); } else if (e.field === 'queryType' && isString(e.value)) { - queryType.set(e.value); - if (queryTarget.value !== '') { + setValue('queryType', e.value); + setFormValue('queryType', e.value); + if (form.queryTarget !== '') { // Reset queryTarget as well, so that, for example, selecting BGP Community, and selecting // a community, then changing the queryType to BGP Route doesn't preserve the selected // community as the queryTarget. - queryTarget.set(''); - displayTarget.set(''); + setFormValue('queryTarget', ''); + setTarget({ display: '' }); } } else if (e.field === 'queryTarget' && isString(e.value)) { - queryTarget.set(e.value); - } else if (e.field === 'queryGroup' && isString(e.value)) { - // queryGroup.set(e.value); - handleGroupChange(e.value); + setFormValue('queryTarget', e.value); } } useEffect(() => { register('queryLocation', { required: true }); - // register('queryTarget', { required: true }); register('queryType', { required: true }); - register('queryGroup'); }, [register]); return ( @@ -295,25 +187,16 @@ export const LookingGlass: React.FC = () => { <FormField name="queryLocation" label={web.text.queryLocation}> <QueryLocation onChange={handleChange} label={web.text.queryLocation} /> </FormField> - <If c={availableGroups.length > 1}> - <FormField label={web.text.queryGroup} name="queryGroup"> - <QueryGroup - label={web.text.queryGroup} - groups={availableGroups.value} - onChange={handleChange} - /> - </FormField> - </If> </FormRow> <FormRow> - <SlideFade offsetX={-100} in={availableTypes.length > 1} unmountOnExit> + <SlideFade offsetY={100} in={filtered.types.length > 0} unmountOnExit> <FormField name="queryType" label={web.text.queryType} labelAddOn={ <HelpModal - visible={selectedDirective?.info.value !== null} - item={selectedDirective?.info.value ?? null} + visible={directive?.info !== null} + item={directive?.info ?? null} name="queryType" /> } @@ -321,14 +204,14 @@ export const LookingGlass: React.FC = () => { <QueryType onChange={handleChange} label={web.text.queryType} /> </FormField> </SlideFade> - <SlideFade offsetX={100} in={selectedDirective !== null} unmountOnExit> - {selectedDirective !== null && ( + <SlideFade offsetX={100} in={directive !== null} unmountOnExit> + {directive !== null && ( <FormField name="queryTarget" label={web.text.queryTarget}> <QueryTarget name="queryTarget" register={register} onChange={handleChange} - placeholder={selectedDirective.description.value} + placeholder={directive.description} /> </FormField> )} @@ -344,8 +227,8 @@ export const LookingGlass: React.FC = () => { flexDir="column" mr={{ base: 0, lg: 2 }} > - <ScaleFade initialScale={0.5} in={queryTarget.value !== ''}> - <SubmitButton handleChange={handleChange} /> + <ScaleFade initialScale={0.5} in={form.queryTarget !== ''}> + <SubmitButton /> </ScaleFade> </Flex> </FormRow> diff --git a/hyperglass/ui/components/path/path.tsx b/hyperglass/ui/components/path/path.tsx index 7aaa349..ced1a7b 100644 --- a/hyperglass/ui/components/path/path.tsx +++ b/hyperglass/ui/components/path/path.tsx @@ -9,7 +9,7 @@ import { ModalCloseButton, } from '@chakra-ui/react'; import { useColorValue, useBreakpointValue } from '~/context'; -import { useLGState, useLGMethods } from '~/hooks'; +import { useFormState } from '~/hooks'; import { PathButton } from './button'; import { Chart } from './chart'; @@ -17,8 +17,8 @@ import type { TPath } from './types'; export const Path: React.FC<TPath> = (props: TPath) => { const { device } = props; - const { displayTarget } = useLGState(); - const { getResponse } = useLGMethods(); + const displayTarget = useFormState(s => s.target.display); + const getResponse = useFormState(s => s.response); const { isOpen, onClose, onOpen } = useDisclosure(); const response = getResponse(device); const output = response?.output as StructuredResponse; @@ -35,7 +35,7 @@ export const Path: React.FC<TPath> = (props: TPath) => { maxH={{ base: '80%', lg: '60%' }} maxW={{ base: '100%', lg: '80%' }} > - <ModalHeader>{`Path to ${displayTarget.value}`}</ModalHeader> + <ModalHeader>{`Path to ${displayTarget}`}</ModalHeader> <ModalCloseButton /> <ModalBody> {response !== null ? <Chart data={output} /> : <Skeleton w="500px" h="300px" />} diff --git a/hyperglass/ui/components/results/group.tsx b/hyperglass/ui/components/results/group.tsx index a86d52a..105cc3b 100644 --- a/hyperglass/ui/components/results/group.tsx +++ b/hyperglass/ui/components/results/group.tsx @@ -2,14 +2,12 @@ import { useEffect } from 'react'; import { Accordion } from '@chakra-ui/react'; import { AnimatePresence } from 'framer-motion'; import { AnimatedDiv } from '~/components'; -import { useDevice, useLGState } from '~/hooks'; +import { useFormState } from '~/hooks'; import { Result } from './individual'; import { Tags } from './tags'; export const Results: React.FC = () => { - const { queryLocation, queryTarget, queryType, queryGroup } = useLGState(); - - const getDevice = useDevice(); + const { queryLocation } = useFormState(s => s.form); // Scroll to the top of the page when results load - primarily for mobile. useEffect(() => { @@ -38,20 +36,9 @@ export const Results: React.FC = () => { > <Accordion allowMultiple allowToggle> <AnimatePresence> - {queryLocation.value && - queryLocation.map((loc, i) => { - const device = getDevice(loc.value); - return ( - <Result - index={i} - device={device} - key={device.id} - queryLocation={loc.value} - queryType={queryType.value} - queryGroup={queryGroup.value} - queryTarget={queryTarget.value} - /> - ); + {queryLocation.length > 0 && + queryLocation.map((location, index) => { + return <Result index={index} key={location} queryLocation={location} />; })} </AnimatePresence> </Accordion> diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx index 59ccffc..bd08047 100644 --- a/hyperglass/ui/components/results/individual.tsx +++ b/hyperglass/ui/components/results/individual.tsx @@ -1,23 +1,24 @@ -import { forwardRef, useEffect, useMemo } from 'react'; +import { forwardRef, memo, useEffect, useMemo } from 'react'; import { Box, Flex, - chakra, Icon, Alert, + chakra, HStack, Tooltip, AccordionItem, AccordionPanel, - useAccordionContext, AccordionButton, + useAccordionContext, } from '@chakra-ui/react'; import { motion } from 'framer-motion'; import { BsLightningFill } from '@meronex/icons/bs'; import { startCase } from 'lodash'; +import isEqual from 'react-fast-compare'; import { BGPTable, Countdown, TextOutput, If, Path } from '~/components'; import { useColorValue, useConfig, useMobile } from '~/context'; -import { useStrf, useLGQuery, useLGState, useTableToString } from '~/hooks'; +import { useStrf, useLGQuery, useTableToString, useFormState, useDevice } from '~/hooks'; import { isStructuredOutput, isStringOutput } from '~/types'; import { isStackError, isFetchError, isLGError, isLGOutputOrError } from './guards'; import { RequeryButton } from './requeryButton'; @@ -25,7 +26,7 @@ import { CopyButton } from './copyButton'; import { FormattedError } from './error'; import { ResultHeader } from './header'; -import type { TResult, TErrorLevels } from './types'; +import type { ResultProps, TErrorLevels } from './types'; const AnimatedAccordionItem = motion(AccordionItem); @@ -38,11 +39,15 @@ const AccordionHeaderWrapper = chakra('div', { }, }); -const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: TResult, ref) => { - const { index, device, queryType, queryTarget, queryLocation, queryGroup } = props; - +const _Result: React.ForwardRefRenderFunction<HTMLDivElement, ResultProps> = ( + props: ResultProps, + ref, +) => { + const { index, queryLocation } = props; const { web, cache, messages } = useConfig(); const { index: indices, setIndex } = useAccordionContext(); + const getDevice = useDevice(); + const device = getDevice(queryLocation); const isMobile = useMobile(); const color = useColorValue('black', 'white'); @@ -50,20 +55,26 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400'); const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); - const { responses } = useLGState(); - - const { data, error, isError, isLoading, refetch, isFetchedAfterMount } = useLGQuery({ - queryLocation, - queryTarget, - queryType, - queryGroup, - }); + const addResponse = useFormState(s => s.addResponse); + const form = useFormState(s => s.form); + const { data, error, isError, isLoading, refetch, isFetchedAfterMount } = useLGQuery( + { + queryLocation, + queryTarget: form.queryTarget, + queryType: form.queryType, + }, + { + onSuccess(data) { + addResponse(device.id, data); + }, + onError(error) { + console.error(error); + }, + }, + ); const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [data, isFetchedAfterMount]); - if (typeof data !== 'undefined') { - responses.merge({ [device.id]: data }); - } const strF = useStrf(); const cacheLabel = strF(web.text.cacheIcon, { time: data?.timestamp }); @@ -92,8 +103,6 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: } }, [error, data, messages.general, messages.requestTimeout]); - isError && console.error(error); - const errorLevel = useMemo<TErrorLevels>(() => { const statusMap = { success: 'success', @@ -113,15 +122,15 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: const tableComponent = useMemo<boolean>(() => { let result = false; - if (typeof queryType.match(/^bgp_\w+$/) !== null && data?.format === 'application/json') { + if (data?.format === 'application/json') { result = true; } return result; - }, [queryType, data?.format]); + }, [data?.format]); let copyValue = data?.output as string; - const formatData = useTableToString(queryTarget, data, [data?.format]); + const formatData = useTableToString(form.queryTarget, data, [data?.format]); if (data?.format === 'application/json') { copyValue = formatData(); @@ -141,7 +150,6 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: } } }, [data, index, indices, isLoading, isError, setIndex]); - return ( <AnimatedAccordionItem ref={ref} @@ -250,4 +258,4 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: ); }; -export const Result = forwardRef<HTMLDivElement, TResult>(_Result); +export const Result = memo(forwardRef<HTMLDivElement, ResultProps>(_Result), isEqual); diff --git a/hyperglass/ui/components/results/tags.tsx b/hyperglass/ui/components/results/tags.tsx index 8575d34..7e14416 100644 --- a/hyperglass/ui/components/results/tags.tsx +++ b/hyperglass/ui/components/results/tags.tsx @@ -3,7 +3,7 @@ import { Box, Stack, useToken } from '@chakra-ui/react'; import { motion, AnimatePresence } from 'framer-motion'; import { Label } from '~/components'; import { useConfig, useBreakpointValue } from '~/context'; -import { useLGState, useLGMethods } from '~/hooks'; +import { useFormState } from '~/hooks'; import type { Transition } from 'framer-motion'; @@ -11,24 +11,16 @@ const transition = { duration: 0.3, delay: 0.5 } as Transition; export const Tags: React.FC = () => { const { web } = useConfig(); - const { queryLocation, queryTarget, queryType, queryGroup } = useLGState(); - const { getDirective } = useLGMethods(); + const form = useFormState(s => s.form); + const getDirective = useFormState(s => s.getDirective); const selectedDirective = useMemo(() => { - if (queryType.value === '') { - return null; - } - const directive = getDirective(queryType.value); - if (directive !== null) { - return directive; - } - return null; + return getDirective(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [queryType.value, queryGroup.value, getDirective]); + }, [form.queryType, getDirective]); const targetBg = useToken('colors', 'teal.600'); const queryBg = useToken('colors', 'cyan.500'); - const vrfBg = useToken('colors', 'blue.500'); const animateLeft = useBreakpointValue({ base: { opacity: 1, x: 0 }, @@ -37,12 +29,12 @@ export const Tags: React.FC = () => { xl: { opacity: 1, x: 0 }, }); - const animateCenter = useBreakpointValue({ - base: { opacity: 1 }, - md: { opacity: 1 }, - lg: { opacity: 1 }, - xl: { opacity: 1 }, - }); + // const animateCenter = useBreakpointValue({ + // base: { opacity: 1 }, + // md: { opacity: 1 }, + // lg: { opacity: 1 }, + // xl: { opacity: 1 }, + // }); const animateRight = useBreakpointValue({ base: { opacity: 1, x: 0 }, @@ -58,12 +50,12 @@ export const Tags: React.FC = () => { xl: { opacity: 0, x: '-100%' }, }); - const initialCenter = useBreakpointValue({ - base: { opacity: 0 }, - md: { opacity: 0 }, - lg: { opacity: 0 }, - xl: { opacity: 0 }, - }); + // const initialCenter = useBreakpointValue({ + // base: { opacity: 0 }, + // md: { opacity: 0 }, + // lg: { opacity: 0 }, + // xl: { opacity: 0 }, + // }); const initialRight = useBreakpointValue({ base: { opacity: 0, x: '100%' }, @@ -83,7 +75,7 @@ export const Tags: React.FC = () => { > <Stack isInline align="center" justify="center" mt={4} flexWrap="wrap"> <AnimatePresence> - {queryLocation.value && ( + {form.queryLocation.length > 0 && ( <> <motion.div initial={initialLeft} @@ -95,32 +87,19 @@ export const Tags: React.FC = () => { bg={queryBg} label={web.text.queryType} fontSize={{ base: 'xs', md: 'sm' }} - value={selectedDirective?.value.name ?? 'None'} - /> - </motion.div> - <motion.div - initial={initialCenter} - animate={animateCenter} - exit={{ opacity: 0, scale: 0.5 }} - transition={transition} - > - <Label - bg={targetBg} - value={queryTarget.value} - label={web.text.queryTarget} - fontSize={{ base: 'xs', md: 'sm' }} + value={selectedDirective?.name ?? 'None'} /> </motion.div> <motion.div initial={initialRight} animate={animateRight} - exit={{ opacity: 0, x: '100%' }} + exit={{ opacity: 0, scale: 0.5 }} transition={transition} > <Label - bg={vrfBg} - label={web.text.queryGroup} - value={queryGroup.value} + bg={targetBg} + value={form.queryTarget} + label={web.text.queryTarget} fontSize={{ base: 'xs', md: 'sm' }} /> </motion.div> diff --git a/hyperglass/ui/components/results/types.ts b/hyperglass/ui/components/results/types.ts index 45e9589..afaadec 100644 --- a/hyperglass/ui/components/results/types.ts +++ b/hyperglass/ui/components/results/types.ts @@ -1,7 +1,5 @@ -import type { State } from '@hookstate/core'; import type { ButtonProps } from '@chakra-ui/react'; import type { UseQueryResult } from 'react-query'; -import type { Device } from '~/types'; export interface TResultHeader { title: string; @@ -17,13 +15,9 @@ export interface TFormattedError { message: string; } -export interface TResult { +export interface ResultProps { index: number; - device: Device; - queryGroup: string; - queryTarget: string; queryLocation: string; - queryType: string; } export type TErrorLevels = 'success' | 'warning' | 'error'; @@ -35,18 +29,3 @@ export interface TCopyButton extends ButtonProps { export interface TRequeryButton extends ButtonProps { requery: UseQueryResult<QueryResponse>['refetch']; } - -export type TUseResults = { - firstOpen: number | null; - locations: { [k: string]: { complete: boolean; open: boolean; index: number } }; -}; - -export type TUseResultsMethods = { - toggle(loc: string): void; - setComplete(loc: string): void; - getOpen(): number[]; -}; - -export type UseResultsReturn = { - results: State<TUseResults>; -} & TUseResultsMethods; diff --git a/hyperglass/ui/components/results/useResults.ts b/hyperglass/ui/components/results/useResults.ts deleted file mode 100644 index a8957e1..0000000 --- a/hyperglass/ui/components/results/useResults.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect } from 'react'; -import { createState, useState } from '@hookstate/core'; - -import type { Plugin, State, PluginStateControl } from '@hookstate/core'; -import type { TUseResults, TUseResultsMethods, UseResultsReturn } from './types'; - -const MethodsId = Symbol('UseResultsMethods'); - -/** - * Plugin methods. - */ -class MethodsInstance { - /** - * Toggle a location's open/closed state. - */ - public toggle(state: State<TUseResults>, loc: string) { - state.locations[loc].open.set(p => !p); - } - /** - * Set a location's completion state. - */ - public setComplete(state: State<TUseResults>, loc: string) { - state.locations[loc].merge({ complete: true }); - const thisLoc = state.locations[loc]; - if ( - state.firstOpen.value === null && - state.locations.keys.includes(loc) && - state.firstOpen.value !== thisLoc.index.value - ) { - state.firstOpen.set(thisLoc.index.value); - this.toggle(state, loc); - } - } - /** - * Get the currently open panels. Passed to Chakra UI's index prop for internal state management. - */ - public getOpen(state: State<TUseResults>) { - const open = state.locations.keys - .filter(k => state.locations[k].complete.value && state.locations[k].open.value) - .map(k => state.locations[k].index.value); - return open; - } -} - -/** - * hookstate plugin to provide convenience functions & tracking for the useResults hook. - */ -function Methods(inst?: State<TUseResults>): Plugin | TUseResultsMethods { - if (inst) { - const [instance] = inst.attach(MethodsId) as [ - MethodsInstance | Error, - PluginStateControl<TUseResults>, - ]; - - if (instance instanceof Error) { - throw instance; - } - - return { - toggle: (loc: string) => instance.toggle(inst, loc), - setComplete: (loc: string) => instance.setComplete(inst, loc), - getOpen: () => instance.getOpen(inst), - } as TUseResultsMethods; - } - return { - id: MethodsId, - init: () => { - /* eslint @typescript-eslint/ban-types: 0 */ - return new MethodsInstance() as {}; - }, - } as Plugin; -} -const initialState = { firstOpen: null, locations: {} } as TUseResults; -const resultsState = createState<TUseResults>(initialState); - -/** - * Track the state of each result, and whether or not each panel is open. - */ -export function useResults(initial: TUseResults['locations']): UseResultsReturn { - // Initialize the global state before instantiating the hook, only once. - useEffect(() => { - if (resultsState.firstOpen.value === null && resultsState.locations.keys.length === 0) { - resultsState.set({ firstOpen: null, locations: initial }); - } - }, [initial]); - - const results = useState(resultsState); - results.attach(Methods as () => Plugin); - - const methods = Methods(results) as TUseResultsMethods; - - // Reset the state on unmount. - useEffect(() => { - return () => { - results.set(initialState); - }; - }, [results]); - - return { results, ...methods }; -} diff --git a/hyperglass/ui/components/select/index.ts b/hyperglass/ui/components/select/index.ts index c739673..cbd13cf 100644 --- a/hyperglass/ui/components/select/index.ts +++ b/hyperglass/ui/components/select/index.ts @@ -1 +1,2 @@ export * from './select'; +export type { TOptions } from './types'; diff --git a/hyperglass/ui/components/select/option.tsx b/hyperglass/ui/components/select/option.tsx new file mode 100644 index 0000000..a733ac8 --- /dev/null +++ b/hyperglass/ui/components/select/option.tsx @@ -0,0 +1,25 @@ +import { Badge, Box, HStack } from '@chakra-ui/react'; +import { components } from 'react-select'; + +import type { TOption } from './types'; + +export const Option = (props: TOption): JSX.Element => { + const { label, data } = props; + const tags = Array.isArray(data.tags) ? (data.tags as string[]) : []; + return ( + <components.Option {...props}> + <Box as="span" d={{ base: 'block', lg: 'inline' }}> + {label} + </Box> + {tags.length > 0 && ( + <HStack d={{ base: 'flex', lg: 'inline-flex' }} ms={{ base: 0, lg: 2 }} alignItems="center"> + {tags.map(tag => ( + <Badge fontSize="xs" variant="subtle" key={tag} colorScheme="gray" textTransform="none"> + {tag} + </Badge> + ))} + </HStack> + )} + </components.Option> + ); +}; diff --git a/hyperglass/ui/components/select/select.tsx b/hyperglass/ui/components/select/select.tsx index 2d43429..0557db2 100644 --- a/hyperglass/ui/components/select/select.tsx +++ b/hyperglass/ui/components/select/select.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useMemo } from 'react'; import ReactSelect from 'react-select'; import { chakra, useDisclosure } from '@chakra-ui/react'; import { useColorMode } from '~/context'; +import { Option } from './option'; import { useRSTheme, useMenuStyle, @@ -17,16 +18,16 @@ import { useIndicatorSeparatorStyle, } from './styles'; -import type { TSelectOption } from '~/types'; +import type { SingleOption } from '~/types'; import type { TSelectBase, TSelectContext, TReactSelectChakra } from './types'; -const SelectContext = createContext<TSelectContext>(Object()); +const SelectContext = createContext<TSelectContext>({} as TSelectContext); export const useSelectContext = (): TSelectContext => useContext(SelectContext); const ReactSelectChakra = chakra<typeof ReactSelect, TReactSelectChakra>(ReactSelect); export const Select: React.FC<TSelectBase> = (props: TSelectBase) => { - const { options, multi, onSelect, isError = false, ...rest } = props; + const { options, multi, onSelect, isError = false, components, ...rest } = props; const { isOpen, onOpen, onClose } = useDisclosure(); const { colorMode } = useColorMode(); @@ -36,7 +37,7 @@ export const Select: React.FC<TSelectBase> = (props: TSelectBase) => { [colorMode, isError, isOpen], ); - const defaultOnChange = (changed: TSelectOption | TSelectOption[]) => { + const defaultOnChange = (changed: SingleOption | SingleOption[]) => { if (!Array.isArray(changed)) { changed = [changed]; } @@ -61,6 +62,7 @@ export const Select: React.FC<TSelectBase> = (props: TSelectBase) => { options={options} isMulti={multi} theme={rsTheme} + components={{ Option, ...components }} styles={{ menuPortal, multiValue, diff --git a/hyperglass/ui/components/select/types.ts b/hyperglass/ui/components/select/types.ts index d717a7a..aca4828 100644 --- a/hyperglass/ui/components/select/types.ts +++ b/hyperglass/ui/components/select/types.ts @@ -14,13 +14,13 @@ import type { Styles as RSStyles, } from 'react-select'; import type { BoxProps } from '@chakra-ui/react'; -import type { Theme, TSelectOption, TSelectOptionMulti, TSelectOptionGroup } from '~/types'; +import type { Theme, SingleOption, OptionGroup } from '~/types'; export interface TSelectState { [k: string]: string[]; } -export type TOptions = Array<TSelectOptionGroup | TSelectOption>; +export type TOptions = Array<SingleOption | OptionGroup>; export type TReactSelectChakra = Omit<IReactSelect, 'isMulti' | 'onSelect' | 'onChange'> & Omit<BoxProps, 'onChange' | 'onSelect'>; @@ -31,8 +31,8 @@ export interface TSelectBase extends TReactSelectChakra { isError?: boolean; options: TOptions; required?: boolean; - onSelect?: (s: TSelectOption[]) => void; - onChange?: (c: TSelectOption | TSelectOptionMulti) => void; + onSelect?: (s: SingleOption[]) => void; + onChange?: (c: SingleOption | SingleOption[]) => void; colorScheme?: Theme.ColorNames; } diff --git a/hyperglass/ui/components/submit/submit.tsx b/hyperglass/ui/components/submit/submit.tsx index 4e718e8..223c410 100644 --- a/hyperglass/ui/components/submit/submit.tsx +++ b/hyperglass/ui/components/submit/submit.tsx @@ -17,10 +17,10 @@ import { FiSearch } from '@meronex/icons/fi'; import { useFormContext } from 'react-hook-form'; import { If, ResolvedTarget } from '~/components'; import { useMobile, useColorValue } from '~/context'; -import { useLGState, useLGMethods } from '~/hooks'; +import { useFormState } from '~/hooks'; import type { IconButtonProps } from '@chakra-ui/react'; -import type { TSubmitButton, TRSubmitButton } from './types'; +import type { SubmitButtonProps, ResponsiveSubmitButtonProps } from './types'; const _SubmitIcon: React.ForwardRefRenderFunction< HTMLButtonElement, @@ -47,8 +47,8 @@ const SubmitIcon = forwardRef<HTMLButtonElement, Omit<IconButtonProps, 'aria-lab /** * Mobile Submit Button */ -const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => { - const { children, isOpen, onClose, onChange } = props; +const MSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => { + const { children, isOpen, onClose } = props; const bg = useColorValue('white', 'gray.900'); return ( <> @@ -66,7 +66,7 @@ const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => { <ModalContent bg={bg}> <ModalCloseButton /> <ModalBody px={4} py={10}> - {isOpen && <ResolvedTarget setTarget={onChange} errorClose={onClose} />} + {isOpen && <ResolvedTarget errorClose={onClose} />} </ModalBody> </ModalContent> </Modal> @@ -77,8 +77,8 @@ const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => { /** * Desktop Submit Button */ -const DSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => { - const { children, isOpen, onClose, onChange } = props; +const DSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => { + const { children, isOpen, onClose } = props; const bg = useColorValue('white', 'gray.900'); return ( <Popover isOpen={isOpen} onClose={onClose} closeOnBlur={false}> @@ -86,19 +86,24 @@ const DSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => { <PopoverContent bg={bg}> <PopoverArrow bg={bg} /> <PopoverCloseButton /> - <PopoverBody p={6}> - {isOpen && <ResolvedTarget setTarget={onChange} errorClose={onClose} />} - </PopoverBody> + <PopoverBody p={6}>{isOpen && <ResolvedTarget errorClose={onClose} />}</PopoverBody> </PopoverContent> </Popover> ); }; -export const SubmitButton: React.FC<TSubmitButton> = (props: TSubmitButton) => { - const { handleChange } = props; +export const SubmitButton = (props: SubmitButtonProps): JSX.Element => { const isMobile = useMobile(); - const { resolvedIsOpen, btnLoading } = useLGState(); - const { resolvedClose, resetForm } = useLGMethods(); + const loading = useFormState(s => s.loading); + const { + resolvedIsOpen, + resolvedClose, + reset: resetForm, + } = useFormState(({ resolvedIsOpen, resolvedClose, reset }) => ({ + resolvedIsOpen, + resolvedClose, + reset, + })); const { reset } = useFormContext(); @@ -111,13 +116,13 @@ export const SubmitButton: React.FC<TSubmitButton> = (props: TSubmitButton) => { return ( <> <If c={isMobile}> - <MSubmitButton isOpen={resolvedIsOpen.value} onClose={handleClose} onChange={handleChange}> - <SubmitIcon isLoading={btnLoading.value} /> + <MSubmitButton isOpen={resolvedIsOpen} onClose={handleClose}> + <SubmitIcon isLoading={loading} {...props} /> </MSubmitButton> </If> <If c={!isMobile}> - <DSubmitButton isOpen={resolvedIsOpen.value} onClose={handleClose} onChange={handleChange}> - <SubmitIcon isLoading={btnLoading.value} /> + <DSubmitButton isOpen={resolvedIsOpen} onClose={handleClose}> + <SubmitIcon isLoading={loading} {...props} /> </DSubmitButton> </If> </> diff --git a/hyperglass/ui/components/submit/types.ts b/hyperglass/ui/components/submit/types.ts index 91ca90b..689679f 100644 --- a/hyperglass/ui/components/submit/types.ts +++ b/hyperglass/ui/components/submit/types.ts @@ -1,13 +1,9 @@ import type { IconButtonProps } from '@chakra-ui/react'; -import type { OnChangeArgs } from '~/types'; -export interface TSubmitButton extends Omit<IconButtonProps, 'aria-label'> { - handleChange(e: OnChangeArgs): void; -} +export type SubmitButtonProps = Omit<IconButtonProps, 'aria-label'>; -export interface TRSubmitButton { +export interface ResponsiveSubmitButtonProps { isOpen: boolean; onClose(): void; - onChange(e: OnChangeArgs): void; children: React.ReactNode; } diff --git a/hyperglass/ui/components/util/animated.tsx b/hyperglass/ui/components/util/animated.tsx index 901fe89..39054ec 100644 --- a/hyperglass/ui/components/util/animated.tsx +++ b/hyperglass/ui/components/util/animated.tsx @@ -1,8 +1,14 @@ -/* eslint react/display-name: off */ -import { Box, forwardRef } from '@chakra-ui/react'; +import { chakra, Box, forwardRef } from '@chakra-ui/react'; import { motion, isValidMotionProp } from 'framer-motion'; import type { BoxProps } from '@chakra-ui/react'; +import type { CustomDomComponent, Transition, MotionProps } from 'framer-motion'; + +type MCComponent = Parameters<typeof chakra>[0]; +type MCOptions = Parameters<typeof chakra>[1]; +type MakeMotionProps<P extends BoxProps> = React.PropsWithChildren< + Omit<P, 'transition'> & Omit<MotionProps, 'transition'> & { transition?: Transition } +>; /** * Combined Chakra + Framer Motion component. @@ -16,3 +22,18 @@ export const AnimatedDiv = motion( return <Box ref={ref} {...chakraProps} />; }), ); + +/** + * Combine `chakra` and `motion` factories. + * + * @param component Component or string + * @param options `chakra` options + * @returns Chakra component with motion props. + */ +export function motionChakra<P extends BoxProps = BoxProps>( + component: MCComponent, + options?: MCOptions, +): CustomDomComponent<MakeMotionProps<P>> { + // @ts-expect-error I don't know how to fix this. + return motion<P>(chakra<MCComponent, P>(component, options)); +} diff --git a/hyperglass/ui/context/types.ts b/hyperglass/ui/context/types.ts index a9cf0c1..60eac45 100644 --- a/hyperglass/ui/context/types.ts +++ b/hyperglass/ui/context/types.ts @@ -1,18 +1,6 @@ -import type { State } from '@hookstate/core'; -import type { Config, FormData } from '~/types'; +import type { Config } from '~/types'; export interface THyperglassProvider { config: Config; children: React.ReactNode; } - -export interface TGlobalState { - isSubmitting: boolean; - formData: FormData; -} - -export interface TUseGlobalState { - isSubmitting: State<TGlobalState['isSubmitting']>; - formData: State<TGlobalState['formData']>; - resetForm(): void; -} diff --git a/hyperglass/ui/hooks/index.ts b/hyperglass/ui/hooks/index.ts index 1cafd0d..46aeb93 100644 --- a/hyperglass/ui/hooks/index.ts +++ b/hyperglass/ui/hooks/index.ts @@ -3,11 +3,11 @@ export * from './useBooleanValue'; export * from './useDevice'; export * from './useDirective'; export * from './useDNSQuery'; +export * from './useFormState'; export * from './useGoogleAnalytics'; export * from './useGreeting'; export * from './useHyperglassConfig'; export * from './useLGQuery'; -export * from './useLGState'; export * from './useOpposingColor'; export * from './useStrf'; export * from './useTableToString'; diff --git a/hyperglass/ui/hooks/types.ts b/hyperglass/ui/hooks/types.ts index 5ac4dac..5bf9f7f 100644 --- a/hyperglass/ui/hooks/types.ts +++ b/hyperglass/ui/hooks/types.ts @@ -1,22 +1,33 @@ -import type { State } from '@hookstate/core'; +import type { UseQueryOptions } from 'react-query'; import type * as ReactGA from 'react-ga'; -import type { Device, Families, TFormQuery, TSelectOption, Directive } from '~/types'; +import type { Device, TFormQuery } from '~/types'; export type LGQueryKey = [string, TFormQuery]; export type DNSQueryKey = [string, { target: string | null; family: 4 | 6 }]; +export type LGQueryOptions = Omit< + UseQueryOptions<QueryResponse, Response | QueryResponse | Error, QueryResponse, LGQueryKey>, + | 'queryKey' + | 'queryFn' + | 'cacheTime' + | 'refetchOnWindowFocus' + | 'refetchInterval' + | 'refetchOnMount' +>; + export interface TOpposingOptions { light?: string; dark?: string; } -export type TUseGreetingReturn = { - ack: State<boolean>; - isOpen: State<boolean>; +export interface UseGreeting { + isAck: boolean; + isOpen: boolean; + greetingReady: boolean; + ack(value: boolean): void; open(): void; close(): void; - greetingReady(): boolean; -}; +} export type TUseDevice = ( /** @@ -25,50 +36,6 @@ export type TUseDevice = ( deviceId: string, ) => Device; -export interface TSelections { - queryLocation: TSelectOption[] | []; - queryType: TSelectOption | null; - queryGroup: TSelectOption | null; -} - -export interface TMethodsExtension { - getResponse(d: string): QueryResponse | null; - resolvedClose(): void; - resolvedOpen(): void; - formReady(): boolean; - resetForm(): void; - stateExporter<O extends unknown>(o: O): O | null; - getDirective(n: string): Nullable<State<Directive>>; -} - -export type TLGState = { - queryGroup: string; - families: Families; - queryTarget: string; - btnLoading: boolean; - isSubmitting: boolean; - displayTarget: string; - directive: Directive | null; - queryType: string; - queryLocation: string[]; - availableGroups: string[]; - availableTypes: Directive[]; - resolvedIsOpen: boolean; - selections: TSelections; - responses: { [d: string]: QueryResponse }; -}; - -export type TLGStateHandlers = { - exportState<S extends unknown | null>(s: S): S | null; - getResponse(d: string): QueryResponse | null; - resolvedClose(): void; - resolvedOpen(): void; - formReady(): boolean; - resetForm(): void; - stateExporter<O extends unknown>(o: O): O | null; - getDirective(n: string): Nullable<State<Directive>>; -}; - export type UseStrfArgs = { [k: string]: unknown } | string; export type TTableToStringFormatter = diff --git a/hyperglass/ui/hooks/useDirective.ts b/hyperglass/ui/hooks/useDirective.ts index ee2cc3c..960923e 100644 --- a/hyperglass/ui/hooks/useDirective.ts +++ b/hyperglass/ui/hooks/useDirective.ts @@ -1,21 +1,18 @@ import { useMemo } from 'react'; -import { useLGMethods, useLGState } from './useLGState'; +import { useFormState } from './useFormState'; import type { Directive } from '~/types'; export function useDirective(): Nullable<Directive> { - const { queryType, queryGroup } = useLGState(); - const { getDirective } = useLGMethods(); + const { getDirective, form } = useFormState(({ getDirective, form }) => ({ getDirective, form })); return useMemo((): Nullable<Directive> => { - if (queryType.value === '') { + if (form.queryType === '') { return null; } - const directive = getDirective(queryType.value); - if (directive !== null) { - return directive.value; - } - return null; + const directive = getDirective(); + return directive; + // eslint-disable-next-line react-hooks/exhaustive-deps - }, [queryType.value, queryGroup.value, getDirective]); + }, [form.queryType, getDirective]); } diff --git a/hyperglass/ui/hooks/useFormState.ts b/hyperglass/ui/hooks/useFormState.ts new file mode 100644 index 0000000..88a9318 --- /dev/null +++ b/hyperglass/ui/hooks/useFormState.ts @@ -0,0 +1,235 @@ +import { useMemo } from 'react'; +import create from 'zustand'; +import { intersectionWith } from 'lodash'; +import plur from 'plur'; +import isEqual from 'react-fast-compare'; +import { all, andJoin, dedupObjectArray, withDev } from '~/util'; + +import type { StateCreator } from 'zustand'; +import type { UseFormSetError, UseFormClearErrors } from 'react-hook-form'; +import type { SingleOption, Directive, FormData, Text } from '~/types'; +import type { TUseDevice } from './types'; + +type FormStatus = 'form' | 'results'; + +interface FormValues { + queryLocation: string[]; + queryTarget: string; + queryType: string; +} + +/** + * Selected *options*, vs. values. + */ +interface FormSelections { + queryLocation: SingleOption[]; + queryType: SingleOption | null; +} + +interface Filtered { + types: Directive[]; + groups: string[]; +} + +interface Responses { + [deviceId: string]: QueryResponse; +} + +interface Target { + display: string; +} + +interface FormStateType { + // Values + filtered: Filtered; + form: FormValues; + loading: boolean; + responses: Responses; + selections: FormSelections; + status: FormStatus; + target: Target; + resolvedIsOpen: boolean; + + // Methods + resolvedOpen(): void; + resolvedClose(): void; + response(deviceId: string): QueryResponse | null; + addResponse(deviceId: string, data: QueryResponse): void; + setLoading(value: boolean): void; + setStatus(value: FormStatus): void; + setSelection<K extends keyof FormSelections>(field: K, value: FormSelections[K]): void; + setTarget(update: Partial<Target>): void; + getDirective(): Directive | null; + reset(): void; + setFormValue<K extends keyof FormValues>(field: K, value: FormValues[K]): void; + locationChange( + locations: string[], + extra: { + setError: UseFormSetError<FormData>; + clearErrors: UseFormClearErrors<FormData>; + getDevice: TUseDevice; + text: Text; + }, + ): void; +} + +const formState: StateCreator<FormStateType> = (set, get) => ({ + filtered: { types: [], groups: [] }, + form: { queryLocation: [], queryTarget: '', queryType: '' }, + loading: false, + responses: {}, + selections: { queryLocation: [], queryType: null }, + status: 'form', + target: { display: '' }, + resolvedIsOpen: false, + + setFormValue<K extends keyof FormValues>(field: K, value: FormValues[K]): void { + set(state => ({ form: { ...state.form, [field]: value } })); + }, + + setLoading(loading: boolean): void { + set({ loading }); + }, + + setStatus(status: FormStatus): void { + set({ status }); + }, + + setSelection<K extends keyof FormSelections>(field: K, value: FormSelections[K]): void { + set(state => ({ selections: { ...state.selections, [field]: value } })); + }, + + setTarget(update: Partial<Target>): void { + set(state => ({ target: { ...state.target, ...update } })); + }, + + resolvedOpen(): void { + set({ resolvedIsOpen: true }); + }, + + resolvedClose(): void { + set({ resolvedIsOpen: false }); + }, + + addResponse(deviceId: string, data: QueryResponse): void { + set(state => ({ responses: { ...state.responses, [deviceId]: data } })); + }, + + getDirective(): Directive | null { + const { form, filtered } = get(); + const [matching] = filtered.types.filter(t => t.id === form.queryType); + if (typeof matching !== 'undefined') { + return matching; + } + return null; + }, + + locationChange( + locations: string[], + extra: { + setError: UseFormSetError<FormData>; + clearErrors: UseFormClearErrors<FormData>; + getDevice: TUseDevice; + text: Text; + }, + ): void { + const { setError, clearErrors, getDevice, text } = extra; + + clearErrors('queryLocation'); + const locationNames = [] as string[]; + const allGroups = [] as string[][]; + const allTypes = [] as Directive[][]; + const allDevices = []; + set(state => ({ form: { ...state.form, queryLocation: locations } })); + + for (const loc of locations) { + const device = getDevice(loc); + locationNames.push(device.name); + allDevices.push(device); + const groups = new Set<string>(); + for (const directive of device.directives) { + for (const group of directive.groups) { + groups.add(group); + } + } + allGroups.push(Array.from(groups)); + } + + const intersecting = intersectionWith(...allGroups, isEqual); + + for (const group of intersecting) { + for (const device of allDevices) { + for (const directive of device.directives) { + if (directive.groups.includes(group)) { + allTypes.push(device.directives); + } + } + } + } + + let intersectingTypes = intersectionWith(...allTypes, isEqual); + intersectingTypes = dedupObjectArray<Directive>(intersectingTypes, 'id'); + set({ filtered: { groups: intersecting, types: intersectingTypes } }); + + // If there is more than one location selected, but there are no intersecting groups, show an error. + if (locations.length > 1 && intersecting.length === 0) { + setError('queryLocation', { + message: `${locationNames.join(', ')} have no groups in common.`, + }); + } + // If there is only one intersecting group, set it as the form value so the user doesn't have to. + const { selections, form } = get(); + if (form.queryLocation.length > 1 && intersectingTypes.length === 0) { + const start = plur(text.queryLocation, selections.queryLocation.length); + const locationsAnd = andJoin(selections.queryLocation.map(s => s.label)); + const types = plur(text.queryType, 2); + const message = `${start} ${locationsAnd} have no ${types} in common.`; + setError('queryLocation', { + // message: `${locationNames.join(', ')} have no query types in common.`, + message, + }); + } else if (intersectingTypes.length === 1) { + set(state => ({ form: { ...state.form, queryType: intersectingTypes[0].id } })); + } + }, + + response(deviceId: string): QueryResponse | null { + const { responses } = get(); + for (const [id, response] of Object.entries(responses)) { + if (id === deviceId) { + return response; + } + } + return null; + }, + + reset(): void { + set({ + filtered: { types: [], groups: [] }, + form: { queryLocation: [], queryTarget: '', queryType: '' }, + loading: false, + responses: {}, + selections: { queryLocation: [], queryType: null }, + status: 'form', + target: { display: '' }, + resolvedIsOpen: false, + }); + }, +}); + +export const useFormState = create<FormStateType>( + withDev<FormStateType>(formState, 'useFormState'), +); + +export function useView(): FormStatus { + const { status, form } = useFormState(({ status, form }) => ({ status, form })); + return useMemo(() => { + const ready = all( + status === 'results', + form.queryLocation.length !== 0, + form.queryType !== '', + form.queryTarget !== '', + ); + return ready ? 'results' : 'form'; + }, [status, form]); +} diff --git a/hyperglass/ui/hooks/useGoogleAnalytics.tsx b/hyperglass/ui/hooks/useGoogleAnalytics.tsx index b2e4e75..13c90d2 100644 --- a/hyperglass/ui/hooks/useGoogleAnalytics.tsx +++ b/hyperglass/ui/hooks/useGoogleAnalytics.tsx @@ -1,23 +1,37 @@ +import create from 'zustand'; import { useCallback } from 'react'; -import { createState, useState } from '@hookstate/core'; import * as ReactGA from 'react-ga'; import type { GAEffect, GAReturn } from './types'; -const enabledState = createState<boolean>(false); +interface EnabledState { + enabled: boolean; + enable(): void; + disable(): void; +} + +const useEnabled = create<EnabledState>(set => ({ + enabled: false, + enable() { + set({ enabled: true }); + }, + disable() { + set({ enabled: false }); + }, +})); export function useGoogleAnalytics(): GAReturn { - const enabled = useState<boolean>(enabledState); + const { enabled, enable } = useEnabled(({ enable, enabled }) => ({ enable, enabled })); const runEffect = useCallback( (effect: GAEffect): void => { - if (typeof window !== 'undefined' && enabled.value) { + if (typeof window !== 'undefined' && enabled) { if (typeof effect === 'function') { effect(ReactGA); } } }, - [enabled.value], + [enabled], ); const trackEvent = useCallback( @@ -77,7 +91,7 @@ export function useGoogleAnalytics(): GAReturn { return; } - enabled.set(true); + enable(); const initializeOpts = { titleCase: false } as ReactGA.InitializeOptions; @@ -89,7 +103,7 @@ export function useGoogleAnalytics(): GAReturn { ga.initialize(trackingId, initializeOpts); }); }, - [runEffect, enabled], + [runEffect, enable], ); return { trackEvent, trackModal, trackPage, initialize, ga: ReactGA }; diff --git a/hyperglass/ui/hooks/useGreeting.ts b/hyperglass/ui/hooks/useGreeting.ts index ea25777..c962ba6 100644 --- a/hyperglass/ui/hooks/useGreeting.ts +++ b/hyperglass/ui/hooks/useGreeting.ts @@ -1,45 +1,54 @@ -import { createState, useState } from '@hookstate/core'; -import { Persistence } from '@hookstate/persistence'; +import create from 'zustand'; +import { persist } from 'zustand/middleware'; import { useConfig } from '~/context'; +import { withDev } from '~/util'; -import type { TUseGreetingReturn } from './types'; +import type { StateSelector, EqualityChecker } from 'zustand'; +import type { UseGreeting } from './types'; -const ackState = createState<boolean>(false); -const openState = createState<boolean>(false); - -/** - * Hook to manage the greeting, a.k.a. the popup at config path web.greeting. - */ -export function useGreeting(): TUseGreetingReturn { - const ack = useState<boolean>(ackState); - const isOpen = useState<boolean>(openState); - const { web } = useConfig(); - - if (typeof window !== 'undefined') { - ack.attach(Persistence('hyperglass-greeting')); +export function useGreeting(): UseGreeting; +export function useGreeting<U extends ValueOf<UseGreeting>>( + selector: StateSelector<UseGreeting, U>, + equalityFn?: EqualityChecker<U>, +): U; +export function useGreeting<U extends Partial<UseGreeting>>( + selector: StateSelector<UseGreeting, U>, + equalityFn?: EqualityChecker<U>, +): U; +export function useGreeting<U extends UseGreeting>( + selector?: StateSelector<UseGreeting, U>, + equalityFn?: EqualityChecker<U>, +): U { + const { + web: { + greeting: { required }, + }, + } = useConfig(); + const storeFn = create<UseGreeting>( + persist( + withDev<UseGreeting>( + set => ({ + isOpen: false, + isAck: false, + greetingReady: false, + ack(isAck: boolean): void { + const greetingReady = isAck ? true : !required ? true : false; + set(() => ({ isAck, greetingReady, isOpen: false })); + }, + open(): void { + set(() => ({ isOpen: true })); + }, + close(): void { + set(() => ({ isOpen: false })); + }, + }), + 'useGreeting', + ), + { name: 'hyperglass-greeting' }, + ), + ); + if (typeof selector === 'function') { + return storeFn<U>(selector, equalityFn); } - - function open() { - return isOpen.set(true); - } - function close() { - return isOpen.set(false); - } - - function greetingReady(): boolean { - if (ack.get()) { - // If the acknowledgement is already set, no further evaluation is needed. - return true; - } else if (!web.greeting.required && !ack.get()) { - // If the acknowledgement is not set, but is also not required, then pass. - return true; - } else if (web.greeting.required && !ack.get()) { - // If the acknowledgement is not set, but is required, then fail. - return false; - } else { - return false; - } - } - - return { ack, isOpen, greetingReady, open, close }; + return storeFn() as U; } diff --git a/hyperglass/ui/hooks/useLGQuery.ts b/hyperglass/ui/hooks/useLGQuery.ts index ad8a4c2..48a886f 100644 --- a/hyperglass/ui/hooks/useLGQuery.ts +++ b/hyperglass/ui/hooks/useLGQuery.ts @@ -6,12 +6,15 @@ import { fetchWithTimeout } from '~/util'; import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query'; import type { TFormQuery } from '~/types'; -import type { LGQueryKey } from './types'; +import type { LGQueryKey, LGQueryOptions } from './types'; /** * Custom hook handle submission of a query to the hyperglass backend. */ -export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse> { +export function useLGQuery( + query: TFormQuery, + options: LGQueryOptions = {} as LGQueryOptions, +): QueryObserverResult<QueryResponse> { const { requestTimeout, cache } = useConfig(); const controller = useMemo(() => new AbortController(), []); @@ -23,14 +26,13 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse dimension1: query.queryLocation, dimension2: query.queryTarget, dimension3: query.queryType, - dimension4: query.queryGroup, }); const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async ( ctx: QueryFunctionContext<LGQueryKey>, ): Promise<QueryResponse> => { const [url, data] = ctx.queryKey; - const { queryLocation, queryTarget, queryType, queryGroup } = data; + const { queryLocation, queryTarget, queryType } = data; const res = await fetchWithTimeout( url, { @@ -40,7 +42,6 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse queryLocation, queryTarget, queryType, - queryGroup, }), mode: 'cors', }, @@ -73,5 +74,6 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse refetchInterval: false, // Don't refetch on component remount. refetchOnMount: false, + ...options, }); } diff --git a/hyperglass/ui/hooks/useLGState.ts b/hyperglass/ui/hooks/useLGState.ts deleted file mode 100644 index 047da54..0000000 --- a/hyperglass/ui/hooks/useLGState.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useCallback } from 'react'; -import { useState, createState } from '@hookstate/core'; -import isEqual from 'react-fast-compare'; -import { all } from '~/util'; - -import type { State, PluginStateControl, Plugin } from '@hookstate/core'; -import type { TLGState, TLGStateHandlers, TMethodsExtension } from './types'; -import type { Directive } from '~/types'; - -const MethodsId = Symbol('Methods'); - -/** - * hookstate plugin to provide convenience functions for the useLGState hook. - */ -class MethodsInstance { - /** - * Set the DNS resolver Popover to opened. - */ - public resolvedOpen(state: State<TLGState>) { - state.resolvedIsOpen.set(true); - } - - /** - * Set the DNS resolver Popover to closed. - */ - public resolvedClose(state: State<TLGState>) { - state.resolvedIsOpen.set(false); - } - - /** - * Find a response based on the device ID. - */ - public getResponse(state: State<TLGState>, device: string): QueryResponse | null { - if (device in state.responses) { - return state.responses[device].value; - } else { - return null; - } - } - - public getDirective(state: State<TLGState>, name: string): Nullable<State<Directive>> { - const [directive] = state.availableTypes.filter(t => t.id.value === name); - if (typeof directive !== 'undefined') { - return directive; - } - return null; - } - - /** - * Determine if the form is ready for submission, e.g. all fields have values and isSubmitting - * has been set to true. This ultimately controls the UI layout. - */ - public formReady(state: State<TLGState>): boolean { - return ( - state.isSubmitting.value && - all( - ...[ - state.queryType.value !== '', - state.queryGroup.value !== '', - state.queryTarget.value !== '', - state.queryLocation.length !== 0, - ], - ) - ); - } - - /** - * Reset form values affected by the form state to their default values. - */ - public resetForm(state: State<TLGState>) { - state.merge({ - families: [], - queryType: '', - queryGroup: '', - responses: {}, - queryTarget: '', - queryLocation: [], - displayTarget: '', - btnLoading: false, - isSubmitting: false, - resolvedIsOpen: false, - availableGroups: [], - availableTypes: [], - selections: { queryLocation: [], queryType: null, queryGroup: null }, - }); - } - - public stateExporter<O extends unknown>(obj: O): O | null { - let result = null; - if (obj === null) { - return result; - } - try { - result = JSON.parse(JSON.stringify(obj)); - } catch (err) { - let error; - if (err instanceof Error) { - error = err.message; - } else { - error = String(err); - } - console.error(error); - } - return result; - } -} - -/** - * Plugin Initialization. - */ -function Methods(): Plugin; -/** - * Plugin Attachment. - */ -function Methods(inst: State<TLGState>): TMethodsExtension; -/** - * Plugin Instance. - */ -function Methods(inst?: State<TLGState>): Plugin | TMethodsExtension { - if (inst) { - const [instance] = inst.attach(MethodsId) as [ - MethodsInstance | Error, - PluginStateControl<TLGState>, - ]; - - if (instance instanceof Error) { - throw instance; - } - - return { - resetForm: () => instance.resetForm(inst), - formReady: () => instance.formReady(inst), - resolvedOpen: () => instance.resolvedOpen(inst), - resolvedClose: () => instance.resolvedClose(inst), - getResponse: device => instance.getResponse(inst, device), - stateExporter: obj => instance.stateExporter(obj), - getDirective: name => instance.getDirective(inst, name), - }; - } - return { - id: MethodsId, - init: () => { - /* eslint @typescript-eslint/ban-types: 0 */ - return new MethodsInstance() as {}; - }, - }; -} - -const LGState = createState<TLGState>({ - selections: { queryLocation: [], queryType: null, queryGroup: null }, - resolvedIsOpen: false, - isSubmitting: false, - availableGroups: [], - availableTypes: [], - directive: null, - displayTarget: '', - queryLocation: [], - btnLoading: false, - queryTarget: '', - queryGroup: '', - queryType: '', - responses: {}, - families: [], -}); - -/** - * Global state hook for state used throughout hyperglass. - */ -export function useLGState(): State<TLGState> { - return useState<TLGState>(LGState); -} - -/** - * Plugin for useLGState() that provides convenience methods for its state. - */ -export function useLGMethods(): TLGStateHandlers { - const state = useLGState(); - state.attach(Methods); - // eslint-disable-next-line react-hooks/exhaustive-deps - const exporter = useCallback(Methods(state).stateExporter, [isEqual]); - return { - exportState(s) { - return exporter(s); - }, - ...Methods(state), - }; -} diff --git a/hyperglass/ui/package.json b/hyperglass/ui/package.json index e87c110..df08ee4 100644 --- a/hyperglass/ui/package.json +++ b/hyperglass/ui/package.json @@ -16,12 +16,11 @@ "browserslist": "> 0.25%, not dead", "dependencies": { "@chakra-ui/react": "^1.6.3", + "@choc-ui/chakra-autocomplete": "^4.5.10", "@emotion/react": "^11.4.0", "@emotion/styled": "^11.3.0", "@hookform/devtools": "^3.1.0", "@hookform/resolvers": "^2.5.1", - "@hookstate/core": "^3.0.7", - "@hookstate/persistence": "^3.0.0", "@meronex/icons": "^4.0.0", "dagre": "^0.8.5", "dayjs": "^1.10.4", @@ -29,6 +28,7 @@ "lodash": "^4.17.21", "next": "^11.1.2", "palette-by-numbers": "^0.1.5", + "plur": "^4.0.0", "react": "^17.0.2", "react-countdown": "^2.2.1", "react-device-detect": "^1.15.0", @@ -43,10 +43,10 @@ "react-table": "^7.7.0", "remark-gfm": "^1.0.0", "string-format": "^2.0.0", - "vest": "^3.2.3" + "vest": "^3.2.3", + "zustand": "^3.5.10" }, "devDependencies": { - "@hookstate/devtools": "^3.0.0", "@types/dagre": "^0.7.44", "@types/node": "^14.14.41", "@types/react": "^17.0.3", diff --git a/hyperglass/ui/pages/_app.tsx b/hyperglass/ui/pages/_app.tsx index 00fc54d..0de9912 100644 --- a/hyperglass/ui/pages/_app.tsx +++ b/hyperglass/ui/pages/_app.tsx @@ -3,10 +3,6 @@ import { QueryClient, QueryClientProvider } from 'react-query'; import type { AppProps } from 'next/app'; -if (process.env.NODE_ENV === 'development') { - require('@hookstate/devtools'); -} - const queryClient = new QueryClient(); const App = (props: AppProps): JSX.Element => { diff --git a/hyperglass/ui/types/common.ts b/hyperglass/ui/types/common.ts index 5efd424..aea14ab 100644 --- a/hyperglass/ui/types/common.ts +++ b/hyperglass/ui/types/common.ts @@ -1,22 +1,17 @@ -import { State } from '@hookstate/core'; - -export type TSelectOptionBase = { +type AnyOption = { label: string; +}; + +export type SingleOption = AnyOption & { value: string; group?: string; + tags?: string[]; }; -export type TSelectOption = TSelectOptionBase | null; - -export type TSelectOptionMulti = TSelectOptionBase[] | null; - -export type TSelectOptionState = State<TSelectOption>; - -export type TSelectOptionGroup = { - label: string; - options: TSelectOption[]; +export type OptionGroup = AnyOption & { + options: SingleOption[]; }; +export type SelectOption<T extends unknown = unknown> = (SingleOption | OptionGroup) & { data: T }; + export type OnChangeArgs = { field: string; value: string | string[] }; - -export type Families = [4] | [6] | [4, 6] | []; diff --git a/hyperglass/ui/types/data.ts b/hyperglass/ui/types/data.ts index 8858f56..7af2079 100644 --- a/hyperglass/ui/types/data.ts +++ b/hyperglass/ui/types/data.ts @@ -5,7 +5,6 @@ export interface FormData { queryLocation: string[]; queryType: string; queryTarget: string; - queryGroup: string; } export interface TFormQuery extends Omit<FormData, 'queryLocation'> { diff --git a/hyperglass/ui/types/guards.ts b/hyperglass/ui/types/guards.ts index c7ab9ab..ad3dc1d 100644 --- a/hyperglass/ui/types/guards.ts +++ b/hyperglass/ui/types/guards.ts @@ -1,6 +1,4 @@ -import type { State } from '@hookstate/core'; import type { FormData, TStringTableData, TQueryResponseString } from './data'; -import type { TSelectOption } from './common'; import type { QueryContent, DirectiveSelect, Directive } from './config'; export function isString(a: unknown): a is string { @@ -31,24 +29,6 @@ export function isQueryContent(content: unknown): content is QueryContent { return isObject(content) && 'content' in content; } -/** - * Determine if an object is a Select option. - */ -export function isSelectOption(a: unknown): a is NonNullable<TSelectOption> { - return isObject(a) && 'label' in a && 'value' in a; -} - -/** - * Determine if an object is a HookState Proxy. - */ -export function isState<S>(a: unknown): a is State<NonNullable<S>> { - if (isObject(a) && 'get' in a && 'set' in a && 'promised' in a) { - const obj = a as { get: never; set: never; promised: never }; - return typeof obj.get === 'function' && typeof obj.set === 'function'; - } - return false; -} - /** * Determine if a form field name is a valid form key name. */ diff --git a/hyperglass/ui/util/common.ts b/hyperglass/ui/util/common.ts index 21a362f..9bc29ff 100644 --- a/hyperglass/ui/util/common.ts +++ b/hyperglass/ui/util/common.ts @@ -125,3 +125,53 @@ export function dedupObjectArray<E extends Record<string, unknown>, P extends ke } }, []); } + +interface AndJoinOptions { + /** + * Separator for last item. + * + * @default '&' + */ + separator?: string; + + /** + * Use the oxford comma. + * + * @default true + */ + oxfordComma?: boolean; + + /** + * Wrap each item in a character. + * + * @default '' + */ + wrap?: string; +} + +/** + * Create a natural list of values from an array of strings + * @param values + * @param options + * @returns + */ +export function andJoin(values: string[], options?: AndJoinOptions): string { + let mergedOptions = { separator: '&', oxfordComma: true, wrap: '' } as Required<AndJoinOptions>; + if (typeof options === 'object' && options !== null) { + mergedOptions = { ...mergedOptions, ...options }; + } + const { separator, oxfordComma, wrap } = mergedOptions; + const parts = values.filter(v => typeof v === 'string'); + const lastElement = parts.pop(); + if (typeof lastElement === 'undefined') { + return ''; + } + const last = [wrap, lastElement, wrap].join(''); + if (parts.length > 0) { + const main = parts.map(p => [wrap, p, wrap].join('')).join(', '); + const comma = oxfordComma && parts.length > 2 ? ',' : ''; + const result = `${main}${comma} ${separator} ${last}`; + return result.trim(); + } + return last; +} diff --git a/hyperglass/ui/util/index.ts b/hyperglass/ui/util/index.ts index bcb6134..9c0325e 100644 --- a/hyperglass/ui/util/index.ts +++ b/hyperglass/ui/util/index.ts @@ -1,3 +1,4 @@ export * from './common'; export * from './config'; +export * from './state'; export * from './theme'; diff --git a/hyperglass/ui/util/state.ts b/hyperglass/ui/util/state.ts new file mode 100644 index 0000000..40a70b9 --- /dev/null +++ b/hyperglass/ui/util/state.ts @@ -0,0 +1,20 @@ +import { devtools } from 'zustand/middleware'; + +import type { StateCreator } from 'zustand'; + +/** + * Wrap a zustand state function with devtools, if applicable. + * + * @param store zustand store function. + * @param name Store name. + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export function withDev<T extends object = {}>( + store: StateCreator<T>, + name: string, +): StateCreator<T> { + if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') { + return devtools<T>(store, { name }); + } + return store; +} diff --git a/hyperglass/ui/yarn.lock b/hyperglass/ui/yarn.lock index 8ffdbc6..63e5fa5 100644 --- a/hyperglass/ui/yarn.lock +++ b/hyperglass/ui/yarn.lock @@ -741,6 +741,13 @@ dependencies: "@chakra-ui/utils" "1.8.0" +"@choc-ui/chakra-autocomplete@^4.5.10": + version "4.5.10" + resolved "https://registry.yarnpkg.com/@choc-ui/chakra-autocomplete/-/chakra-autocomplete-4.5.10.tgz#0d98043c001e9aef26f7400201a2fda24cb301c3" + integrity sha512-nxEdtV2x5pzU0gOkN4inNVeRRxTjrY0JJ40MYWEAgL6mFM8AogzVSU2X/l8uutEIVz0P+hu60/96Vhu10eNxOA== + dependencies: + react-nanny "^2.9.0" + "@emotion/babel-plugin@^11.3.0": version "11.3.0" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.3.0.tgz#3a16850ba04d8d9651f07f3fb674b3436a4fb9d7" @@ -946,24 +953,6 @@ resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.5.1.tgz#09f2228c9061c350819881dc11e4f65af90c5a1a" integrity sha512-dNChuOJ7ker2ZALGhXO6KjeycwnBGcSslGsREgqTDSiBOgbEysupfGTJKzQsb0sXpNhrZLCGvQcT+qPmCdBEMw== -"@hookstate/core@^3.0.7": - version "3.0.7" - resolved "https://registry.yarnpkg.com/@hookstate/core/-/core-3.0.7.tgz#aa91c3ca93771abda4a9729302c088d35bb7abaa" - integrity sha512-9aOGwD4tezrXJ7AkiPg0MJ8d9TiRCusf537zPOV4vOJmNn/tyMDMDxd1T4OlqKvNurVTzs9BEIMOuq5OduV7/w== - -"@hookstate/devtools@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@hookstate/devtools/-/devtools-3.0.0.tgz#9b289503112f0f95221d0c86e602ada7793bbd3a" - integrity sha512-jXI9e+4+dr0FbdtHhQPa0/SmRwLM7twMU0Qf87AgM3DqqFDV9dRPD56jPXaTh8xD1Bt2R72TmGmqdkDhdKG8AQ== - dependencies: - redux "4.0.5" - redux-devtools-extension "2.13.8" - -"@hookstate/persistence@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@hookstate/persistence/-/persistence-3.0.0.tgz#973b30d3a3bb1b29f4f59b1afe0d914e71a23882" - integrity sha512-xiN22IW0Wjw/uTV1OEUpTtqB8DAJyRobYR3+iv1TyMf72o98KpaZqQrLPvXkfrjlZac5s052BypjpkttX/Cmhw== - "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" @@ -3649,6 +3638,11 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +irregular-plurals@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/irregular-plurals/-/irregular-plurals-3.3.0.tgz#67d0715d4361a60d9fd9ee80af3881c631a31ee2" + integrity sha512-MVBLKUTangM3EfRPFROhmWQQKRDsrgI83J8GS3jXy+OwYqiR2/aoWndYQ5416jLE3uaGgLH7ncme3X9y09gZ3g== + is-alphabetical@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" @@ -4844,6 +4838,13 @@ platform@1.3.6: resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7" integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== +plur@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/plur/-/plur-4.0.0.tgz#729aedb08f452645fe8c58ef115bf16b0a73ef84" + integrity sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg== + dependencies: + irregular-plurals "^3.2.0" + pnp-webpack-plugin@1.6.4: version "1.6.4" resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149" @@ -5158,6 +5159,11 @@ react-markdown@^5.0.3: unist-util-visit "^2.0.0" xtend "^4.0.1" +react-nanny@^2.9.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/react-nanny/-/react-nanny-2.10.0.tgz#50543fbbdd4daebb925b9603dae219da7ab7cd93" + integrity sha512-MmrcxrVQT5cimC0ZUYy7ZRl+t9P5hWM7E2LkFWSrWPj0C5MOlXfz4Oeauu3hImIyvoa0SunpLh22Go0L71xAbA== + react-query@^3.16.0: version "3.16.0" resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.16.0.tgz#8de1556aabb3d200d0f8eeb74ce2b0b3dd0a0a51" @@ -5299,19 +5305,6 @@ readdirp@~3.5.0: dependencies: picomatch "^2.2.1" -redux-devtools-extension@2.13.8: - version "2.13.8" - resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1" - integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg== - -redux@4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" - integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== - dependencies: - loose-envify "^1.4.0" - symbol-observable "^1.2.0" - redux@^4.0.0, redux@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4" @@ -5841,11 +5834,6 @@ supports-color@^8.0.0: dependencies: has-flag "^4.0.0" -symbol-observable@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" - integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== - table@^6.0.9: version "6.7.1" resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" @@ -6354,6 +6342,11 @@ 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== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"