1
0
Fork 1
mirror of https://github.com/thatmattlove/hyperglass.git synced 2026-01-17 08:48:05 +00:00

UI improvements, hookstate → zustand migration, cleanup

This commit is contained in:
thatmattlove 2021-09-21 08:20:44 -07:00
parent 7d5d64c0e2
commit 85566b81ab
48 changed files with 1133 additions and 1038 deletions

View file

@ -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<UseDisclosureReturn, 'isOpen' | 'onClose'> {
@ -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 (
<>
<HStack
@ -69,12 +75,20 @@ export const Debugger: React.FC = () => {
<Tag size={tagSize} colorScheme="gray">
{colorMode.toUpperCase()}
</Tag>
<Button size={btnSize} colorScheme="blue" onClick={onConfigOpen}>
<Button size={btnSize} leftIcon={<ConfigIcon />} colorScheme="cyan" onClick={onConfigOpen}>
View Config
</Button>
<Button size={btnSize} colorScheme="red" onClick={onThemeOpen}>
<Button size={btnSize} leftIcon={<ThemeIcon />} colorScheme="blue" onClick={onThemeOpen}>
View Theme
</Button>
<Button
size={btnSize}
colorScheme="purple"
leftIcon={<RefreshIcon />}
onClick={() => refetch()}
>
Reload Config
</Button>
<Tag size={tagSize} colorScheme="teal">
{mediaSize}
</Tag>

View file

@ -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<TField> = (props: TField) => {
@ -14,19 +15,17 @@ export const FormField: React.FC<TField> = (props: TField) => {
const errorColor = useColorValue('red.500', 'red.300');
const opacity = useBooleanValue(hiddenLabels, 0, undefined);
const [error, setError] = useState<Nullable<FieldError>>(null);
const { formState } = useFormContext<FormData>();
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<FieldError | null>(() => {
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 (
<FormControl

View file

@ -1,7 +1,6 @@
export * from './row';
export * from './field';
export * from './queryType';
export * from './queryGroup';
export * from './queryTarget';
export * from './queryLocation';
export * from './resolvedTarget';

View file

@ -1,40 +0,0 @@
import { useMemo } from 'react';
import { Select } from '~/components';
import { useLGMethods, useLGState } from '~/hooks';
import type { TSelectOption } from '~/types';
import type { TQueryGroup } from './types';
export const QueryGroup: React.FC<TQueryGroup> = (props: TQueryGroup) => {
const { onChange, label } = props;
const { selections, availableGroups, queryLocation } = useLGState();
const { exportState } = useLGMethods();
const options = useMemo<TSelectOption[]>(
() => 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 (
<Select
size="lg"
name="queryGroup"
options={options}
aria-label={label}
onChange={handleChange}
value={exportState(selections.queryGroup.value)}
/>
);
};

View file

@ -1,13 +1,15 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Wrap, VStack, Flex, Box, Avatar, chakra } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { useFormContext } from 'react-hook-form';
import { Select } from '~/components';
import { useConfig } from '~/context';
import { useLGState, useLGMethods } from '~/hooks';
import { useConfig, useColorValue } from '~/context';
import { useOpposingColor, useFormState } from '~/hooks';
import type { Network, TSelectOption } from '~/types';
import type { TQuerySelectField } from './types';
import type { Network, SingleOption, OptionGroup, FormData } from '~/types';
import type { TQuerySelectField, LocationCardProps } from './types';
function buildOptions(networks: Network[]) {
function buildOptions(networks: Network[]): OptionGroup[] {
return networks
.map(net => {
const label = net.displayName;
@ -23,42 +25,193 @@ function buildOptions(networks: Network[]) {
.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
}
export const QueryLocation: React.FC<TQuerySelectField> = (props: TQuerySelectField) => {
const MotionBox = motion(Box);
const LocationCard = (props: LocationCardProps): JSX.Element => {
const { option, onChange, defaultChecked, hasError } = props;
const { label } = option;
const [isChecked, setChecked] = useState(defaultChecked);
function handleChange(value: SingleOption) {
if (isChecked) {
setChecked(false);
onChange('remove', value);
} else {
setChecked(true);
onChange('add', value);
}
}
const bg = useColorValue('whiteAlpha.300', 'blackSolid.700');
const imageBorder = useColorValue('gray.600', 'whiteAlpha.800');
const fg = useOpposingColor(bg);
const checkedBorder = useColorValue('blue.400', 'blue.300');
const errorBorder = useColorValue('red.500', 'red.300');
const borderColor = useMemo(
() =>
hasError && isChecked
? // Highlight red when there are no overlapping query types for the locations selected.
errorBorder
: isChecked && !hasError
? // Highlight blue when any location is selected and there is no error.
checkedBorder
: // Otherwise, no border.
'transparent',
[hasError, isChecked, checkedBorder, errorBorder],
);
return (
<MotionBox
py={4}
px={6}
bg={bg}
w="100%"
maxW="sm"
mx="auto"
shadow="lg"
key={label}
rounded="lg"
cursor="pointer"
borderWidth="1px"
borderStyle="solid"
whileHover={{ scale: 1.05 }}
borderColor={borderColor}
onClick={(e: React.MouseEvent) => {
e.preventDefault();
handleChange(option);
}}
>
<Flex justifyContent="space-between" alignItems="center">
<chakra.h2
color={fg}
fontWeight="bold"
mt={{ base: 2, md: 0 }}
fontSize={{ base: 'lg', md: 'xl' }}
>
{label}
</chakra.h2>
<Avatar
color={fg}
fit="cover"
alt={label}
name={label}
boxSize={12}
rounded="full"
borderWidth={1}
bg="whiteAlpha.300"
borderStyle="solid"
borderColor={imageBorder}
/>
</Flex>
<chakra.p mt={2} color={fg} opacity={0.6} fontSize="sm">
To do: add details field (and location image field)
</chakra.p>
</MotionBox>
);
};
export const QueryLocation = (props: TQuerySelectField): JSX.Element => {
const { onChange, label } = props;
const { networks } = useConfig();
const {
formState: { errors },
} = useFormContext();
const { selections } = useLGState();
const { exportState } = useLGMethods();
} = useFormContext<FormData>();
const selections = useFormState(s => s.selections);
const setSelection = useFormState(s => s.setSelection);
const { form, filtered } = useFormState(({ form, filtered }) => ({ form, filtered }));
const options = useMemo(() => buildOptions(networks), [networks]);
const element = useMemo(() => {
const groups = options.length;
const maxOptionsPerGroup = Math.max(...options.map(opt => opt.options.length));
const showCards = groups < 5 && maxOptionsPerGroup < 6;
return showCards ? 'cards' : 'select';
}, [options]);
function handleChange(e: TSelectOption | TSelectOption[]): void {
if (e === null) {
e = [];
} else if (typeof e === 'string') {
e = [e];
}
if (Array.isArray(e)) {
const value = e.map(sel => sel!.value);
onChange({ field: 'queryLocation', value });
selections.queryLocation.set(e);
const noOverlap = useMemo(
() => form.queryLocation.length > 1 && filtered.types.length === 0,
[form, filtered],
);
/**
* Update form and state when a card selections change.
*
* @param action Add or remove the option.
* @param option Full option object.
*/
function handleCardChange(action: 'add' | 'remove', option: SingleOption) {
const exists = selections.queryLocation.map(q => q.value).includes(option.value);
if (action === 'add' && !exists) {
const toAdd = [...form.queryLocation, option.value];
const newSelections = [...selections.queryLocation, option];
setSelection('queryLocation', newSelections);
onChange({ field: 'queryLocation', value: toAdd });
} else if (action === 'remove' && exists) {
const index = selections.queryLocation.findIndex(v => v.value === option.value);
const toRemove = [...form.queryLocation.filter(v => v !== option.value)];
setSelection(
'queryLocation',
selections.queryLocation.filter((_, i) => i !== index),
);
onChange({ field: 'queryLocation', value: toRemove });
}
}
return (
<Select
isMulti
size="lg"
options={options}
aria-label={label}
name="queryLocation"
onChange={handleChange}
closeMenuOnSelect={false}
value={exportState(selections.queryLocation.value)}
isError={typeof errors.queryLocation !== 'undefined'}
/>
);
/**
* 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 (
<Wrap align="flex-start" justify={{ base: 'center', lg: 'space-between' }} shouldWrapChildren>
{options.map(group => (
<VStack key={group.label} align="center">
<chakra.h3 fontSize={{ base: 'sm', md: 'md' }} alignSelf="flex-start" opacity={0.5}>
{group.label}
</chakra.h3>
{group.options.map(opt => {
return (
<LocationCard
key={opt.label}
option={opt}
onChange={handleCardChange}
hasError={noOverlap}
defaultChecked={form.queryLocation.includes(opt.value)}
/>
);
})}
</VStack>
))}
</Wrap>
);
} else if (element === 'select') {
return (
<Select
isMulti
size="lg"
options={options}
aria-label={label}
name="queryLocation"
onChange={handleSelectChange}
closeMenuOnSelect={false}
value={selections.queryLocation}
isError={typeof errors.queryLocation !== 'undefined'}
/>
);
}
return <Flex>No Locations</Flex>;
};

View file

@ -3,14 +3,13 @@ import { Input, Text } from '@chakra-ui/react';
import { components } from 'react-select';
import { If, Select } from '~/components';
import { useColorValue } from '~/context';
import { useLGState, useDirective } from '~/hooks';
import { useDirective, useFormState } from '~/hooks';
import { isSelectDirective } from '~/types';
import type { OptionProps } from 'react-select';
import type { TSelectOption, Directive } from '~/types';
import type { Directive, SingleOption } from '~/types';
import type { TQueryTarget } from './types';
function buildOptions(directive: Nullable<Directive>): TSelectOption[] {
function buildOptions(directive: Nullable<Directive>): SingleOption[] {
if (directive !== null && isSelectDirective(directive)) {
return directive.options.map(o => ({
value: o.value,
@ -41,27 +40,28 @@ export const QueryTarget: React.FC<TQueryTarget> = (props: TQueryTarget) => {
const color = useColorValue('gray.400', 'whiteAlpha.800');
const border = useColorValue('gray.100', 'whiteAlpha.50');
const placeholderColor = useColorValue('gray.600', 'whiteAlpha.700');
const { queryTarget, displayTarget } = useLGState();
const displayTarget = useFormState(s => s.target.display);
const setTarget = useFormState(s => s.setTarget);
const form = useFormState(s => s.form);
const directive = useDirective();
const options = useMemo(() => buildOptions(directive), [directive]);
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>): void {
displayTarget.set(e.target.value);
setTarget({ display: e.target.value });
onChange({ field: name, value: e.target.value });
}
function handleSelectChange(e: TSelectOption | TSelectOption[]): void {
function handleSelectChange(e: SingleOption | SingleOption[]): void {
if (!Array.isArray(e) && e !== null) {
onChange({ field: name, value: e.value });
displayTarget.set(e.value);
setTarget({ display: e.value });
}
}
return (
<>
<input {...register('queryTarget')} hidden readOnly value={queryTarget.value} />
<input {...register('queryTarget')} hidden readOnly value={form.queryTarget} />
<If c={directive !== null && isSelectDirective(directive)}>
<Select
size="lg"
@ -81,7 +81,7 @@ export const QueryTarget: React.FC<TQueryTarget> = (props: TQueryTarget) => {
borderColor={border}
aria-label={placeholder}
placeholder={placeholder}
value={displayTarget.value}
value={displayTarget}
name="queryTargetDisplay"
onChange={handleInputChange}
_placeholder={{ color: placeholderColor }}

View file

@ -1,32 +1,169 @@
import { useMemo } from 'react';
import create from 'zustand';
import { Box, Button, HStack, useRadio, useRadioGroup } from '@chakra-ui/react';
import { useFormContext } from 'react-hook-form';
import { components } from 'react-select';
import { Select } from '~/components';
import { useLGState, useLGMethods } from '~/hooks';
import { useFormState } from '~/hooks';
import type { TSelectOption } from '~/types';
import type { UseRadioProps } from '@chakra-ui/react';
import type { MenuListComponentProps } from 'react-select';
import type { SingleOption, OptionGroup, SelectOption } from '~/types';
import type { TOptions } from '~/components/select';
import type { TQuerySelectField } from './types';
export const QueryType: React.FC<TQuerySelectField> = (props: TQuerySelectField) => {
function sorter<T extends SingleOption | OptionGroup>(a: T, b: T): number {
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
}
type UserFilter = {
selected: string;
setSelected(n: string): void;
filter(candidate: SelectOption<{ group: string | null }>, input: string): boolean;
};
const useFilter = create<UserFilter>((set, get) => ({
selected: '',
setSelected(newValue: string) {
set(() => ({ selected: newValue }));
},
filter(candidate, input): boolean {
const {
label,
data: { group },
} = candidate;
if (input && (label || group)) {
const search = input.toLowerCase();
if (group) {
return label.toLowerCase().indexOf(search) > -1 || group.toLowerCase().indexOf(search) > -1;
}
return label.toLowerCase().indexOf(search) > -1;
}
const { selected } = get();
if (selected !== '' && selected === group) {
return true;
}
if (selected === '') {
return true;
}
return false;
},
}));
function useOptions() {
const filtered = useFormState(s => s.filtered);
return useMemo((): TOptions => {
const groupNames = new Set(
filtered.types
.filter(t => t.groups.length > 0)
.map(t => t.groups)
.flat(),
);
const optGroups: OptionGroup[] = Array.from(groupNames).map(group => ({
label: group,
options: filtered.types
.filter(t => t.groups.includes(group))
.map(t => ({ label: t.name, value: t.id, group }))
.sort(sorter),
}));
const noGroups: OptionGroup = {
label: '',
options: filtered.types
.filter(t => t.groups.length === 0)
.map(t => ({ label: t.name, value: t.id, group: '' }))
.sort(sorter),
};
return [noGroups, ...optGroups].sort(sorter);
}, [filtered.types]);
}
const GroupFilter = (props: React.PropsWithChildren<UseRadioProps>): JSX.Element => {
const { children, ...rest } = props;
const {
getInputProps,
getCheckboxProps,
getLabelProps,
htmlProps,
state: { isChecked },
} = useRadio(rest);
const label = getLabelProps();
const input = getInputProps();
const checkbox = getCheckboxProps();
return (
<Box as="label" {...label}>
<input {...input} />
<Button
{...checkbox}
{...htmlProps}
variant={isChecked ? 'solid' : 'outline'}
colorScheme="gray"
size="sm"
>
{children}
</Button>
</Box>
);
};
const MenuList = (props: MenuListComponentProps<TOptions, false>) => {
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 (
<components.MenuList {...rest}>
<HStack pt={4} px={2} zIndex={2} {...getRootProps()}>
<GroupFilter {...getRadioProps({ value: '', onClick: () => handleClick('') })}>
None
</GroupFilter>
{filtered.groups.map(value => {
return (
<GroupFilter
key={value}
{...getRadioProps({ value, onClick: () => handleClick(value) })}
>
{value}
</GroupFilter>
);
})}
</HStack>
{children}
</components.MenuList>
);
};
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<TQuerySelectField> = (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}
/>
);
};

View file

@ -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<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleRight)),
@ -12,9 +15,6 @@ const LeftArrow = chakra(
dynamic<MeronexIcon>(() => 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<TResolvedTarget> = (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<TResolvedTarget> = (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<TResolvedTarget> = (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 (
<VStack w="100%" spacing={4} justify="center">
{(answer4 || answer6) && (
{hasAnswer && (
<Text fontSize="sm" textAlign="center">
{messageStart}
<Text as="span" fontSize="sm" fontWeight="bold" color={color}>
{`${displayTarget.value}`.toLowerCase()}
{`${displayTarget}`.toLowerCase()}
</Text>
{messageEnd}
</Text>
)}
<Stack spacing={2}>
{!isLoading4 && !isError4 && query4 && answer4 && (
{showA && (
<Button
size="sm"
fontSize="xs"
@ -108,7 +107,7 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
{answer4}
</Button>
)}
{!isLoading6 && !isError6 && query6 && answer6 && (
{showAAAA && (
<Button
size="sm"
fontSize="xs"
@ -122,18 +121,19 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
{answer6}
</Button>
)}
{!answer4 && !answer6 && (
{!hasAnswer && (
<>
<Text fontSize="sm" textAlign="center" color={errorColor}>
{errorStart}
<Text as="span" fontSize="sm" fontWeight="bold">
{`${displayTarget.value}`.toLowerCase()}
{`${displayTarget}`.toLowerCase()}
</Text>
{errorEnd}
</Text>
<Button
colorScheme="red"
variant="outline"
size="sm"
onClick={errorClose}
leftIcon={<LeftArrow />}
>

View file

@ -1,6 +1,6 @@
import type { FormControlProps } from '@chakra-ui/react';
import type { UseFormRegister } from 'react-hook-form';
import type { OnChangeArgs, FormData } from '~/types';
import type { OnChangeArgs, FormData, SingleOption } from '~/types';
export interface TField extends FormControlProps {
name: string;
@ -17,10 +17,6 @@ export interface TQuerySelectField {
label: string;
}
export interface TQueryGroup extends TQuerySelectField {
groups: string[];
}
export interface TQueryTarget {
name: string;
placeholder: string;
@ -28,7 +24,13 @@ export interface TQueryTarget {
onChange(e: OnChangeArgs): void;
}
export interface TResolvedTarget {
setTarget(e: OnChangeArgs): void;
export interface ResolvedTargetProps {
errorClose(): void;
}
export interface LocationCardProps {
option: SingleOption;
defaultChecked: boolean;
onChange(a: 'add' | 'remove', v: SingleOption): void;
hasError: boolean;
}

View file

@ -17,35 +17,22 @@ import type { TGreeting } from './types';
export const Greeting: React.FC<TGreeting> = (props: TGreeting) => {
const { web, content } = useConfig();
const { ack: greetingAck, isOpen, close } = useGreeting();
const { isAck, isOpen, open, ack } = useGreeting();
const bg = useColorValue('white', 'gray.800');
const color = useOpposingColor(bg);
function handleClose(ack: boolean = false): void {
if (web.greeting.required && !greetingAck.value && !ack) {
greetingAck.set(false);
} else if (web.greeting.required && !greetingAck.value && ack) {
greetingAck.set(true);
close();
} else if (web.greeting.required && greetingAck.value) {
close();
} else if (!web.greeting.required) {
greetingAck.set(true);
close();
}
}
useEffect(() => {
if (!greetingAck.value && web.greeting.enable) {
isOpen.set(true);
if (!isAck && web.greeting.enable) {
open();
}
}, [greetingAck.value, isOpen, web.greeting.enable]);
}, [isAck, open, web.greeting.enable]);
return (
<Modal
size="lg"
isCentered
onClose={handleClose}
isOpen={isOpen.value}
onClose={() => ack(false)}
isOpen={isOpen}
motionPreset="slideInBottom"
closeOnEsc={web.greeting.required}
closeOnOverlayClick={web.greeting.required}
@ -67,7 +54,7 @@ export const Greeting: React.FC<TGreeting> = (props: TGreeting) => {
<Markdown content={content.greeting} />
</ModalBody>
<ModalFooter>
<Button colorScheme="primary" onClick={() => handleClose(true)}>
<Button colorScheme="primary" onClick={() => ack(true)}>
{web.greeting.button}
</Button>
</ModalFooter>

View file

@ -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<THeader> = (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<THeader> = (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' }}
>
<Title />
</AnimatedDiv>

View file

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

View file

@ -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 (

View file

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

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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" />}

View file

@ -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>

View file

@ -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);

View file

@ -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>

View file

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

View file

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

View file

@ -1 +1,2 @@
export * from './select';
export type { TOptions } from './types';

View file

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

View file

@ -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,

View file

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

View file

@ -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>
</>

View file

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

View file

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

View file

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

View file

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

View file

@ -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 =

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",

View file

@ -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 => {

View file

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

View file

@ -5,7 +5,6 @@ export interface FormData {
queryLocation: string[];
queryType: string;
queryTarget: string;
queryGroup: string;
}
export interface TFormQuery extends Omit<FormData, 'queryLocation'> {

View file

@ -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.
*/

View file

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

View file

@ -1,3 +1,4 @@
export * from './common';
export * from './config';
export * from './state';
export * from './theme';

View file

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

View file

@ -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"