From 9d9373f8a088abeaff284712aa2d96b75e4db4b4 Mon Sep 17 00:00:00 2001 From: checktheroads Date: Tue, 5 Jan 2021 22:47:51 -0700 Subject: [PATCH] fix results panel expansion, closes #100 --- hyperglass/ui/components/results/group.tsx | 130 +------- hyperglass/ui/components/results/guards.ts | 7 + .../ui/components/results/individual.tsx | 277 ++++++++---------- hyperglass/ui/components/results/tags.tsx | 128 ++++++++ hyperglass/ui/components/results/types.ts | 24 +- .../ui/components/results/useResults.ts | 100 +++++++ 6 files changed, 389 insertions(+), 277 deletions(-) create mode 100644 hyperglass/ui/components/results/tags.tsx create mode 100644 hyperglass/ui/components/results/useResults.ts diff --git a/hyperglass/ui/components/results/group.tsx b/hyperglass/ui/components/results/group.tsx index e7cb5a9..4b1da0b 100644 --- a/hyperglass/ui/components/results/group.tsx +++ b/hyperglass/ui/components/results/group.tsx @@ -1,72 +1,15 @@ -import { useEffect, useState } from 'react'; -import { Accordion, Box, Stack, useToken } from '@chakra-ui/react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { AnimatedDiv, Label } from '~/components'; -import { useConfig, useBreakpointValue } from '~/context'; +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 { isQueryType } from '~/types'; import { Result } from './individual'; +import { Tags } from './tags'; export const Results: React.FC = () => { - const { queries, vrfs, web } = useConfig(); const { queryLocation, queryTarget, queryType, queryVrf } = useLGState(); const getDevice = useDevice(); - 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 }, - md: { opacity: 1, x: 0 }, - lg: { opacity: 1, x: 0 }, - xl: { opacity: 1, x: 0 }, - }); - - const animateCenter = useBreakpointValue({ - base: { opacity: 1 }, - md: { opacity: 1 }, - lg: { opacity: 1 }, - xl: { opacity: 1 }, - }); - - const animateRight = useBreakpointValue({ - base: { opacity: 1, x: 0 }, - md: { opacity: 1, x: 0 }, - lg: { opacity: 1, x: 0 }, - xl: { opacity: 1, x: 0 }, - }); - - const initialLeft = useBreakpointValue({ - base: { opacity: 0, x: -100 }, - md: { opacity: 0, x: -100 }, - lg: { opacity: 0, x: -100 }, - xl: { opacity: 0, x: -100 }, - }); - - const initialCenter = useBreakpointValue({ - base: { opacity: 0 }, - md: { opacity: 0 }, - lg: { opacity: 0 }, - xl: { opacity: 0 }, - }); - - const initialRight = useBreakpointValue({ - base: { opacity: 0, x: 100 }, - md: { opacity: 0, x: 100 }, - lg: { opacity: 0, x: 100 }, - xl: { opacity: 0, x: 100 }, - }); - - const [resultsComplete, setComplete] = useState([]); - - const matchedVrf = - vrfs.filter(v => v.id === queryVrf.value)[0] ?? vrfs.filter(v => v.id === 'default')[0]; - - let queryTypeLabel = ''; - if (isQueryType(queryType.value)) { - queryTypeLabel = queries[queryType.value].display_name; - } // Scroll to the top of the page when results load - primarily for mobile. useEffect(() => { @@ -77,62 +20,7 @@ export const Results: React.FC = () => { return ( <> - - - - {queryLocation.value && ( - <> - - - - - - - - )} - - - + { animate={{ opacity: 1, y: 0 }} maxW={{ base: '100%', md: '75%' }} > - + {queryLocation.value && queryLocation.map((loc, i) => { const device = getDevice(loc.value); return ( ); })} diff --git a/hyperglass/ui/components/results/guards.ts b/hyperglass/ui/components/results/guards.ts index 3adf06f..5000fe8 100644 --- a/hyperglass/ui/components/results/guards.ts +++ b/hyperglass/ui/components/results/guards.ts @@ -12,3 +12,10 @@ export function isFetchError(error: any): error is Response { export function isLGError(error: any): error is TQueryResponse { return typeof error !== 'undefined' && error !== null && 'output' in error; } + +/** + * Returns true if the response is an LG error, false if not. + */ +export function isLGOutputOrError(data: any): data is TQueryResponse { + return typeof data !== 'undefined' && data !== null && data?.level !== 'success'; +} diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx index ad413b0..fb13d7e 100644 --- a/hyperglass/ui/components/results/individual.tsx +++ b/hyperglass/ui/components/results/individual.tsx @@ -2,12 +2,14 @@ import { forwardRef, useEffect, useMemo } from 'react'; import { Box, Flex, + chakra, Icon, Alert, HStack, Tooltip, AccordionItem, AccordionPanel, + useAccordionContext, AccordionButton, } from '@chakra-ui/react'; import { motion } from 'framer-motion'; @@ -17,43 +19,30 @@ import { BGPTable, Countdown, TextOutput, If, Path } from '~/components'; import { useColorValue, useConfig, useMobile } from '~/context'; import { useStrf, useLGQuery, useLGState, useTableToString } from '~/hooks'; import { isStructuredOutput, isStringOutput } from '~/types'; -import { isStackError, isFetchError, isLGError } from './guards'; +import { isStackError, isFetchError, isLGError, isLGOutputOrError } from './guards'; import { RequeryButton } from './requeryButton'; import { CopyButton } from './copyButton'; import { FormattedError } from './error'; import { ResultHeader } from './header'; -import type { TAccordionHeaderWrapper, TResult, TErrorLevels } from './types'; +import type { TResult, TErrorLevels } from './types'; const AnimatedAccordionItem = motion.custom(AccordionItem); -const AccordionHeaderWrapper: React.FC = ( - props: TAccordionHeaderWrapper, -) => { - const { hoverBg, ...rest } = props; - return ( - - ); -}; +const AccordionHeaderWrapper = chakra('div', { + baseStyle: { + display: 'flex', + justifyContent: 'space-between', + _hover: { bg: 'blackAlpha.50' }, + _focus: { boxShadow: 'outline' }, + }, +}); const _Result: React.ForwardRefRenderFunction = (props: TResult, ref) => { - const { - index, - device, - queryVrf, - queryType, - queryTarget, - setComplete, - queryLocation, - resultsComplete, - } = props; + const { index, device, queryVrf, queryType, queryTarget, queryLocation } = props; const { web, cache, messages } = useConfig(); + const { index: indices, setIndex } = useAccordionContext(); const isMobile = useMobile(); const color = useColorValue('black', 'white'); @@ -82,17 +71,6 @@ const _Result: React.ForwardRefRenderFunction = (props: const cacheLabel = useStrf(web.text.cache_icon, { time: data?.timestamp }, [data?.timestamp]); - const handleToggle = () => { - // Close if open. - if (resultsComplete.includes(index)) { - setComplete(p => p.filter(i => i !== index)); - } - // Open if closed. - else if (!resultsComplete.includes(index)) { - setComplete(p => [...p, index]); - } - }; - const errorKeywords = useMemo(() => { let kw = [] as string[]; if (isLGError(data)) { @@ -101,19 +79,22 @@ const _Result: React.ForwardRefRenderFunction = (props: return kw; }, [data]); - let errorMsg; - - if (isLGError(error)) { - errorMsg = error.output as string; - } else if (isFetchError(error)) { - errorMsg = startCase(error.statusText); - } else if (isStackError(error) && error.message.toLowerCase().startsWith('timeout')) { - errorMsg = messages.request_timeout; - } else if (isStackError(error)) { - errorMsg = startCase(error.message); - } else { - errorMsg = messages.general; - } + // Parse the the response and/or the error to determine from where to extract the error message. + const errorMsg = useMemo(() => { + if (isLGError(error)) { + return error.output as string; + } else if (isLGOutputOrError(data)) { + return data.output as string; + } else if (isFetchError(error)) { + return startCase(error.statusText); + } else if (isStackError(error) && error.message.toLowerCase().startsWith('timeout')) { + return messages.request_timeout; + } else if (isStackError(error)) { + return startCase(error.message); + } else { + return messages.general; + } + }, [isError, error, data]); isError && console.error(error); @@ -132,7 +113,7 @@ const _Result: React.ForwardRefRenderFunction = (props: e = statusMap[idx]; } return e; - }, [error]); + }, [isError, isLoading, data]); const tableComponent = useMemo(() => { let result = false; @@ -154,15 +135,20 @@ const _Result: React.ForwardRefRenderFunction = (props: copyValue = errorMsg; } - // If this is the first completed result, open it. + // Signal to the group that this result is done loading. useEffect(() => { - if (!isLoading && !isError && resultsComplete.length === 0) { - setComplete([index]); + // Only set the index if it's not already set and the query is finished loading. + if (Array.isArray(indices) && indices.length === 0 && !isLoading) { + // Only set the index if the response has data or an error. + if (data || isError) { + setIndex([index]); + } } - }, [isLoading, isError]); + }, [data, isError]); return ( = (props: initial={{ opacity: 0, y: 300 }} transition={{ duration: 0.3, delay: index * 0.3 }} css={{ - '&:last-of-type': { borderBottom: 'none' }, '&:first-of-type': { borderTop: 'none' }, + '&:last-of-type': { borderBottom: 'none' }, }} > - - - - - - {isStructuredOutput(data) && data.level === 'success' && tableComponent && ( - - )} - - - - - - - - {!isError && typeof data !== 'undefined' ? ( - <> - {isStructuredOutput(data) && data.level === 'success' && tableComponent ? ( - {data.output} - ) : isStringOutput(data) && data.level === 'success' && !tableComponent ? ( - {data.output} - ) : isStringOutput(data) && data.level !== 'success' ? ( - - - - ) : ( - - - - )} - - ) : ( - - - + <> + + + + + + {isStructuredOutput(data) && data.level === 'success' && tableComponent && ( + )} - - - - - - - - - - - - - - - - - - + + - - + + + + + {!isError && typeof data !== 'undefined' ? ( + <> + {isStructuredOutput(data) && data.level === 'success' && tableComponent ? ( + {data.output} + ) : isStringOutput(data) && data.level === 'success' && !tableComponent ? ( + {data.output} + ) : isStringOutput(data) && data.level !== 'success' ? ( + + + + ) : ( + + + + )} + + ) : ( + + + + )} + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/hyperglass/ui/components/results/tags.tsx b/hyperglass/ui/components/results/tags.tsx new file mode 100644 index 0000000..a807e5a --- /dev/null +++ b/hyperglass/ui/components/results/tags.tsx @@ -0,0 +1,128 @@ +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 } from '~/hooks'; +import { isQueryType } from '~/types'; + +import type { Transition } from 'framer-motion'; + +const transition = { duration: 0.3, delay: 0.5 } as Transition; + +export const Tags: React.FC = () => { + const { queries, vrfs, web } = useConfig(); + const { queryLocation, queryTarget, queryType, queryVrf } = useLGState(); + + 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 }, + md: { opacity: 1, x: 0 }, + lg: { opacity: 1, x: 0 }, + xl: { opacity: 1, x: 0 }, + }); + + const animateCenter = useBreakpointValue({ + base: { opacity: 1 }, + md: { opacity: 1 }, + lg: { opacity: 1 }, + xl: { opacity: 1 }, + }); + + const animateRight = useBreakpointValue({ + base: { opacity: 1, x: 0 }, + md: { opacity: 1, x: 0 }, + lg: { opacity: 1, x: 0 }, + xl: { opacity: 1, x: 0 }, + }); + + const initialLeft = useBreakpointValue({ + base: { opacity: 0, x: '-100%' }, + md: { opacity: 0, x: '-100%' }, + lg: { opacity: 0, x: '-100%' }, + xl: { opacity: 0, x: '-100%' }, + }); + + const initialCenter = useBreakpointValue({ + base: { opacity: 0 }, + md: { opacity: 0 }, + lg: { opacity: 0 }, + xl: { opacity: 0 }, + }); + + const initialRight = useBreakpointValue({ + base: { opacity: 0, x: '100%' }, + md: { opacity: 0, x: '100%' }, + lg: { opacity: 0, x: '100%' }, + xl: { opacity: 0, x: '100%' }, + }); + + let queryTypeLabel = ''; + if (isQueryType(queryType.value)) { + queryTypeLabel = queries[queryType.value].display_name; + } + + const matchedVrf = + vrfs.filter(v => v.id === queryVrf.value)[0] ?? vrfs.filter(v => v.id === 'default')[0]; + + return ( + + + + {queryLocation.value && ( + <> + + + + + + + + )} + + + + ); +}; diff --git a/hyperglass/ui/components/results/types.ts b/hyperglass/ui/components/results/types.ts index 5559774..10efab5 100644 --- a/hyperglass/ui/components/results/types.ts +++ b/hyperglass/ui/components/results/types.ts @@ -1,4 +1,5 @@ -import type { ButtonProps, FlexProps } from '@chakra-ui/react'; +import type { State } from '@hookstate/core'; +import type { ButtonProps } from '@chakra-ui/react'; import type { UseQueryResult } from 'react-query'; import type { TDevice, TQueryTypes } from '~/types'; @@ -16,10 +17,6 @@ export interface TFormattedError { message: string; } -export interface TAccordionHeaderWrapper extends FlexProps { - hoverBg: FlexProps['bg']; -} - export interface TResult { index: number; device: TDevice; @@ -27,8 +24,6 @@ export interface TResult { queryTarget: string; queryLocation: string; queryType: TQueryTypes; - resultsComplete: number[]; - setComplete: React.Dispatch>; } export type TErrorLevels = 'success' | 'warning' | 'error'; @@ -40,3 +35,18 @@ export interface TCopyButton extends ButtonProps { export interface TRequeryButton extends ButtonProps { requery: UseQueryResult['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; +} & TUseResultsMethods; diff --git a/hyperglass/ui/components/results/useResults.ts b/hyperglass/ui/components/results/useResults.ts new file mode 100644 index 0000000..63fb6cf --- /dev/null +++ b/hyperglass/ui/components/results/useResults.ts @@ -0,0 +1,100 @@ +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, loc: string) { + state.locations[loc].open.set(p => !p); + } + /** + * Set a location's completion state. + */ + public setComplete(state: State, 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) { + 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): Plugin | TUseResultsMethods { + if (inst) { + const [instance] = inst.attach(MethodsId) as [ + MethodsInstance | Error, + PluginStateControl, + ]; + + 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(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 }); + } + }, []); + + 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); + }; + }, []); + + return { results, ...methods }; +}