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

fix results panel expansion, closes #100

This commit is contained in:
checktheroads
2021-01-05 22:47:51 -07:00
parent 949ec9a3d8
commit 9d9373f8a0
6 changed files with 389 additions and 277 deletions

View File

@@ -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<number[]>([]);
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 (
<>
<Box
p={0}
my={4}
w="100%"
mx="auto"
textAlign="left"
maxW={{ base: '100%', lg: '75%', xl: '50%' }}
>
<Stack isInline align="center" justify="center" mt={4} flexWrap="wrap">
<AnimatePresence>
{queryLocation.value && (
<>
<motion.div
initial={initialLeft}
animate={animateLeft}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<Label
bg={queryBg}
label={web.text.query_type}
fontSize={{ base: 'xs', md: 'sm' }}
value={queryTypeLabel}
/>
</motion.div>
<motion.div
initial={initialCenter}
animate={animateCenter}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<Label
bg={targetBg}
value={queryTarget.value}
label={web.text.query_target}
fontSize={{ base: 'xs', md: 'sm' }}
/>
</motion.div>
<motion.div
initial={initialRight}
animate={animateRight}
exit={{ opacity: 0, x: 100 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<Label
bg={vrfBg}
label={web.text.query_vrf}
value={matchedVrf.display_name}
fontSize={{ base: 'xs', md: 'sm' }}
/>
</motion.div>
</>
)}
</AnimatePresence>
</Stack>
</Box>
<Tags />
<AnimatedDiv
p={0}
my={4}
@@ -148,22 +36,20 @@ export const Results: React.FC = () => {
animate={{ opacity: 1, y: 0 }}
maxW={{ base: '100%', md: '75%' }}
>
<Accordion allowMultiple allowToggle index={resultsComplete}>
<Accordion allowMultiple allowToggle>
<AnimatePresence>
{queryLocation.value &&
queryLocation.map((loc, i) => {
const device = getDevice(loc.value);
return (
<Result
key={i}
index={i}
device={device}
key={device.name}
queryLocation={loc.value}
queryVrf={queryVrf.value}
setComplete={setComplete}
queryType={queryType.value}
queryTarget={queryTarget.value}
resultsComplete={resultsComplete}
/>
);
})}

View File

@@ -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';
}

View File

@@ -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<TAccordionHeaderWrapper> = (
props: TAccordionHeaderWrapper,
) => {
const { hoverBg, ...rest } = props;
return (
<Flex
justify="space-between"
_hover={{ bg: hoverBg }}
_focus={{ boxShadow: 'outline' }}
{...rest}
/>
);
};
const AccordionHeaderWrapper = chakra('div', {
baseStyle: {
display: 'flex',
justifyContent: 'space-between',
_hover: { bg: 'blackAlpha.50' },
_focus: { boxShadow: 'outline' },
},
});
const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (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<HTMLDivElement, TResult> = (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<HTMLDivElement, TResult> = (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<HTMLDivElement, TResult> = (props:
e = statusMap[idx];
}
return e;
}, [error]);
}, [isError, isLoading, data]);
const tableComponent = useMemo<boolean>(() => {
let result = false;
@@ -154,15 +135,20 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (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 (
<AnimatedAccordionItem
id={device.name}
ref={ref}
isDisabled={isLoading}
exit={{ opacity: 0, y: 300 }}
@@ -170,105 +156,100 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (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' },
}}
>
<AccordionHeaderWrapper hoverBg="blackAlpha.50">
<AccordionButton
py={2}
w="unset"
_hover={{}}
_focus={{}}
flex="1 0 auto"
onClick={handleToggle}
>
<ResultHeader
isError={isError}
loading={isLoading}
errorMsg={errorMsg}
errorLevel={errorLevel}
runtime={data?.runtime ?? 0}
title={device.display_name}
/>
</AccordionButton>
<HStack py={2} spacing={1}>
{isStructuredOutput(data) && data.level === 'success' && tableComponent && (
<Path device={device.name} />
)}
<CopyButton copyValue={copyValue} isDisabled={isLoading} />
<RequeryButton requery={refetch} isDisabled={isLoading} />
</HStack>
</AccordionHeaderWrapper>
<AccordionPanel
pb={4}
overflowX="auto"
css={{
WebkitOverflowScrolling: 'touch',
'&::-webkit-scrollbar': { height: '5px' },
'&::-webkit-scrollbar-track': {
backgroundColor: scrollbarBg,
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: scrollbar,
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: scrollbarHover,
},
'-ms-overflow-style': { display: 'none' },
}}
>
<Box>
<Flex direction="column" flex="1 0 auto" maxW={error ? '100%' : undefined}>
{!isError && typeof data !== 'undefined' ? (
<>
{isStructuredOutput(data) && data.level === 'success' && tableComponent ? (
<BGPTable>{data.output}</BGPTable>
) : isStringOutput(data) && data.level === 'success' && !tableComponent ? (
<TextOutput>{data.output}</TextOutput>
) : isStringOutput(data) && data.level !== 'success' ? (
<Alert rounded="lg" my={2} py={4} status={errorLevel}>
<FormattedError message={data.output} keywords={errorKeywords} />
</Alert>
) : (
<Alert rounded="lg" my={2} py={4} status={errorLevel}>
<FormattedError message={errorMsg} keywords={errorKeywords} />
</Alert>
)}
</>
) : (
<Alert rounded="lg" my={2} py={4} status={errorLevel}>
<FormattedError message={errorMsg} keywords={errorKeywords} />
</Alert>
<>
<AccordionHeaderWrapper>
<AccordionButton py={2} w="unset" _hover={{}} _focus={{}} flex="1 0 auto">
<ResultHeader
isError={isLGOutputOrError(data)}
loading={isLoading}
errorMsg={errorMsg}
errorLevel={errorLevel}
runtime={data?.runtime ?? 0}
title={device.display_name}
/>
</AccordionButton>
<HStack py={2} spacing={1}>
{isStructuredOutput(data) && data.level === 'success' && tableComponent && (
<Path device={device.name} />
)}
</Flex>
</Box>
<Flex direction="row" flexWrap="wrap">
<HStack
px={3}
mt={2}
spacing={1}
flex="1 0 auto"
justifyContent={{ base: 'flex-start', lg: 'flex-end' }}
>
<If c={cache.show_text && !isError && isCached}>
<If c={!isMobile}>
<Countdown timeout={cache.timeout} text={web.text.cache_prefix} />
</If>
<Tooltip hasArrow label={cacheLabel} placement="top">
<Box>
<Icon as={BsLightningFill} color={color} />
</Box>
</Tooltip>
<If c={isMobile}>
<Countdown timeout={cache.timeout} text={web.text.cache_prefix} />
</If>
</If>
<CopyButton copyValue={copyValue} isDisabled={isLoading} />
<RequeryButton requery={refetch} isDisabled={isLoading} />
</HStack>
</Flex>
</AccordionPanel>
</AccordionHeaderWrapper>
<AccordionPanel
pb={4}
overflowX="auto"
css={{
WebkitOverflowScrolling: 'touch',
'&::-webkit-scrollbar': { height: '5px' },
'&::-webkit-scrollbar-track': {
backgroundColor: scrollbarBg,
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: scrollbar,
},
'&::-webkit-scrollbar-thumb:hover': {
backgroundColor: scrollbarHover,
},
'-ms-overflow-style': { display: 'none' },
}}
>
<Box>
<Flex direction="column" flex="1 0 auto" maxW={error ? '100%' : undefined}>
{!isError && typeof data !== 'undefined' ? (
<>
{isStructuredOutput(data) && data.level === 'success' && tableComponent ? (
<BGPTable>{data.output}</BGPTable>
) : isStringOutput(data) && data.level === 'success' && !tableComponent ? (
<TextOutput>{data.output}</TextOutput>
) : isStringOutput(data) && data.level !== 'success' ? (
<Alert rounded="lg" my={2} py={4} status={errorLevel}>
<FormattedError message={data.output} keywords={errorKeywords} />
</Alert>
) : (
<Alert rounded="lg" my={2} py={4} status={errorLevel}>
<FormattedError message={errorMsg} keywords={errorKeywords} />
</Alert>
)}
</>
) : (
<Alert rounded="lg" my={2} py={4} status={errorLevel}>
<FormattedError message={errorMsg} keywords={errorKeywords} />
</Alert>
)}
</Flex>
</Box>
<Flex direction="row" flexWrap="wrap">
<HStack
px={3}
mt={2}
spacing={1}
flex="1 0 auto"
justifyContent={{ base: 'flex-start', lg: 'flex-end' }}
>
<If c={cache.show_text && !isError && isCached}>
<If c={!isMobile}>
<Countdown timeout={cache.timeout} text={web.text.cache_prefix} />
</If>
<Tooltip hasArrow label={cacheLabel} placement="top">
<Box>
<Icon as={BsLightningFill} color={color} />
</Box>
</Tooltip>
<If c={isMobile}>
<Countdown timeout={cache.timeout} text={web.text.cache_prefix} />
</If>
</If>
</HStack>
</Flex>
</AccordionPanel>
</>
</AnimatedAccordionItem>
);
};

View File

@@ -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 (
<Box
p={0}
my={4}
w="100%"
mx="auto"
textAlign="left"
maxW={{ base: '100%', lg: '75%', xl: '50%' }}
>
<Stack isInline align="center" justify="center" mt={4} flexWrap="wrap">
<AnimatePresence>
{queryLocation.value && (
<>
<motion.div
initial={initialLeft}
animate={animateLeft}
exit={{ opacity: 0, x: '-100%' }}
transition={transition}
>
<Label
bg={queryBg}
label={web.text.query_type}
fontSize={{ base: 'xs', md: 'sm' }}
value={queryTypeLabel}
/>
</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.query_target}
fontSize={{ base: 'xs', md: 'sm' }}
/>
</motion.div>
<motion.div
initial={initialRight}
animate={animateRight}
exit={{ opacity: 0, x: '100%' }}
transition={transition}
>
<Label
bg={vrfBg}
label={web.text.query_vrf}
value={matchedVrf.display_name}
fontSize={{ base: 'xs', md: 'sm' }}
/>
</motion.div>
</>
)}
</AnimatePresence>
</Stack>
</Box>
);
};

View File

@@ -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<React.SetStateAction<number[]>>;
}
export type TErrorLevels = 'success' | 'warning' | 'error';
@@ -40,3 +35,18 @@ export interface TCopyButton extends ButtonProps {
export interface TRequeryButton extends ButtonProps {
requery: UseQueryResult<TQueryResponse>['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;

View File

@@ -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<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 });
}
}, []);
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 };
}