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, useDisclosure,
ModalCloseButton, ModalCloseButton,
} from '@chakra-ui/react'; } 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 { useConfig, useColorValue, useBreakpointValue } from '~/context';
import { CodeBlock } from '~/components'; import { CodeBlock } from '~/components';
import { useHyperglassConfig } from '~/hooks';
import type { UseDisclosureReturn } from '@chakra-ui/react'; import type { UseDisclosureReturn } from '@chakra-ui/react';
interface TViewer extends Pick<UseDisclosureReturn, 'isOpen' | 'onClose'> { 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'; useBreakpointValue({ base: 'SMALL', md: 'MEDIUM', lg: 'LARGE', xl: 'X-LARGE' }) ?? 'UNKNOWN';
const tagSize = useBreakpointValue({ base: 'sm', lg: 'lg' }) ?? 'lg'; const tagSize = useBreakpointValue({ base: 'sm', lg: 'lg' }) ?? 'lg';
const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }) ?? 'sm'; const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }) ?? 'sm';
const { refetch } = useHyperglassConfig();
return ( return (
<> <>
<HStack <HStack
@ -69,12 +75,20 @@ export const Debugger: React.FC = () => {
<Tag size={tagSize} colorScheme="gray"> <Tag size={tagSize} colorScheme="gray">
{colorMode.toUpperCase()} {colorMode.toUpperCase()}
</Tag> </Tag>
<Button size={btnSize} colorScheme="blue" onClick={onConfigOpen}> <Button size={btnSize} leftIcon={<ConfigIcon />} colorScheme="cyan" onClick={onConfigOpen}>
View Config View Config
</Button> </Button>
<Button size={btnSize} colorScheme="red" onClick={onThemeOpen}> <Button size={btnSize} leftIcon={<ThemeIcon />} colorScheme="blue" onClick={onThemeOpen}>
View Theme View Theme
</Button> </Button>
<Button
size={btnSize}
colorScheme="purple"
leftIcon={<RefreshIcon />}
onClick={() => refetch()}
>
Reload Config
</Button>
<Tag size={tagSize} colorScheme="teal"> <Tag size={tagSize} colorScheme="teal">
{mediaSize} {mediaSize}
</Tag> </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 { Flex, FormControl, FormLabel, FormErrorMessage } from '@chakra-ui/react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { If } from '~/components'; import { If } from '~/components';
@ -6,6 +6,7 @@ import { useColorValue } from '~/context';
import { useBooleanValue } from '~/hooks'; import { useBooleanValue } from '~/hooks';
import type { FieldError } from 'react-hook-form'; import type { FieldError } from 'react-hook-form';
import type { FormData } from '~/types';
import type { TField } from './types'; import type { TField } from './types';
export const FormField: React.FC<TField> = (props: TField) => { 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 errorColor = useColorValue('red.500', 'red.300');
const opacity = useBooleanValue(hiddenLabels, 0, undefined); const opacity = useBooleanValue(hiddenLabels, 0, undefined);
const [error, setError] = useState<Nullable<FieldError>>(null); const { formState } = useFormContext<FormData>();
const { const error = useMemo<FieldError | null>(() => {
formState: { errors }, if (name in formState.errors) {
} = useFormContext(); console.group(`Error on field '${label}'`);
console.warn(formState.errors[name as keyof FormData]);
useEffect(() => { console.groupEnd();
if (name in errors) { return formState.errors[name as keyof FormData] as FieldError;
console.dir(errors);
setError(errors[name]);
console.warn(`Error on field '${label}': ${error?.message}`);
} }
}, [error, errors, label, name, setError]); return null;
}, [formState, label, name]);
return ( return (
<FormControl <FormControl

View file

@ -1,7 +1,6 @@
export * from './row'; export * from './row';
export * from './field'; export * from './field';
export * from './queryType'; export * from './queryType';
export * from './queryGroup';
export * from './queryTarget'; export * from './queryTarget';
export * from './queryLocation'; export * from './queryLocation';
export * from './resolvedTarget'; 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 { useFormContext } from 'react-hook-form';
import { Select } from '~/components'; import { Select } from '~/components';
import { useConfig } from '~/context'; import { useConfig, useColorValue } from '~/context';
import { useLGState, useLGMethods } from '~/hooks'; import { useOpposingColor, useFormState } from '~/hooks';
import type { Network, TSelectOption } from '~/types'; import type { Network, SingleOption, OptionGroup, FormData } from '~/types';
import type { TQuerySelectField } from './types'; import type { TQuerySelectField, LocationCardProps } from './types';
function buildOptions(networks: Network[]) { function buildOptions(networks: Network[]): OptionGroup[] {
return networks return networks
.map(net => { .map(net => {
const label = net.displayName; 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)); .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 { onChange, label } = props;
const { networks } = useConfig(); const { networks } = useConfig();
const { const {
formState: { errors }, formState: { errors },
} = useFormContext(); } = useFormContext<FormData>();
const { selections } = useLGState(); const selections = useFormState(s => s.selections);
const { exportState } = useLGMethods(); const setSelection = useFormState(s => s.setSelection);
const { form, filtered } = useFormState(({ form, filtered }) => ({ form, filtered }));
const options = useMemo(() => buildOptions(networks), [networks]); 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 { const noOverlap = useMemo(
if (e === null) { () => form.queryLocation.length > 1 && filtered.types.length === 0,
e = []; [form, filtered],
} else if (typeof e === 'string') { );
e = [e];
} /**
if (Array.isArray(e)) { * Update form and state when a card selections change.
const value = e.map(sel => sel!.value); *
onChange({ field: 'queryLocation', value }); * @param action Add or remove the option.
selections.queryLocation.set(e); * @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 * Update form and state when select element values change.
isMulti *
size="lg" * @param options Final value. React-select determines if an option is being added or removed and
options={options} * only sends back the final value.
aria-label={label} */
name="queryLocation" function handleSelectChange(options: SingleOption[] | SingleOption): void {
onChange={handleChange} if (Array.isArray(options)) {
closeMenuOnSelect={false} onChange({ field: 'queryLocation', value: options.map(o => o.value) });
value={exportState(selections.queryLocation.value)} setSelection('queryLocation', options);
isError={typeof errors.queryLocation !== 'undefined'} } 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 { components } from 'react-select';
import { If, Select } from '~/components'; import { If, Select } from '~/components';
import { useColorValue } from '~/context'; import { useColorValue } from '~/context';
import { useLGState, useDirective } from '~/hooks'; import { useDirective, useFormState } from '~/hooks';
import { isSelectDirective } from '~/types'; import { isSelectDirective } from '~/types';
import type { OptionProps } from 'react-select'; import type { OptionProps } from 'react-select';
import type { TSelectOption, Directive } from '~/types'; import type { Directive, SingleOption } from '~/types';
import type { TQueryTarget } from './types'; import type { TQueryTarget } from './types';
function buildOptions(directive: Nullable<Directive>): TSelectOption[] { function buildOptions(directive: Nullable<Directive>): SingleOption[] {
if (directive !== null && isSelectDirective(directive)) { if (directive !== null && isSelectDirective(directive)) {
return directive.options.map(o => ({ return directive.options.map(o => ({
value: o.value, value: o.value,
@ -41,27 +40,28 @@ export const QueryTarget: React.FC<TQueryTarget> = (props: TQueryTarget) => {
const color = useColorValue('gray.400', 'whiteAlpha.800'); const color = useColorValue('gray.400', 'whiteAlpha.800');
const border = useColorValue('gray.100', 'whiteAlpha.50'); const border = useColorValue('gray.100', 'whiteAlpha.50');
const placeholderColor = useColorValue('gray.600', 'whiteAlpha.700'); const placeholderColor = useColorValue('gray.600', 'whiteAlpha.700');
const displayTarget = useFormState(s => s.target.display);
const { queryTarget, displayTarget } = useLGState(); const setTarget = useFormState(s => s.setTarget);
const form = useFormState(s => s.form);
const directive = useDirective(); const directive = useDirective();
const options = useMemo(() => buildOptions(directive), [directive]); const options = useMemo(() => buildOptions(directive), [directive]);
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>): void { function handleInputChange(e: React.ChangeEvent<HTMLInputElement>): void {
displayTarget.set(e.target.value); setTarget({ display: e.target.value });
onChange({ field: name, value: 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) { if (!Array.isArray(e) && e !== null) {
onChange({ field: name, value: e.value }); onChange({ field: name, value: e.value });
displayTarget.set(e.value); setTarget({ display: e.value });
} }
} }
return ( return (
<> <>
<input {...register('queryTarget')} hidden readOnly value={queryTarget.value} /> <input {...register('queryTarget')} hidden readOnly value={form.queryTarget} />
<If c={directive !== null && isSelectDirective(directive)}> <If c={directive !== null && isSelectDirective(directive)}>
<Select <Select
size="lg" size="lg"
@ -81,7 +81,7 @@ export const QueryTarget: React.FC<TQueryTarget> = (props: TQueryTarget) => {
borderColor={border} borderColor={border}
aria-label={placeholder} aria-label={placeholder}
placeholder={placeholder} placeholder={placeholder}
value={displayTarget.value} value={displayTarget}
name="queryTargetDisplay" name="queryTargetDisplay"
onChange={handleInputChange} onChange={handleInputChange}
_placeholder={{ color: placeholderColor }} _placeholder={{ color: placeholderColor }}

View file

@ -1,32 +1,169 @@
import { useMemo } from 'react'; 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 { useFormContext } from 'react-hook-form';
import { components } from 'react-select';
import { Select } from '~/components'; 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'; 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 { onChange, label } = props;
const { const {
formState: { errors }, formState: { errors },
} = useFormContext(); } = useFormContext();
const { selections, availableTypes, queryType } = useLGState(); const setSelection = useFormState(s => s.setSelection);
const { exportState } = useLGMethods(); 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( function handleChange(e: SingleOption | SingleOption[]): void {
() => availableTypes.map(t => ({ label: t.name.value, value: t.id.value })),
[availableTypes],
);
function handleChange(e: TSelectOption | TSelectOption[]): void {
let value = ''; let value = '';
if (!Array.isArray(e) && e !== null) { if (!Array.isArray(e) && e !== null) {
selections.queryType.set(e); // setFormValue('queryType', e.value);
setSelection('queryType', e);
value = e.value; value = e.value;
} else { } else {
selections.queryType.set(null); setFormValue('queryType', '');
queryType.set(''); setSelection('queryType', null);
} }
onChange({ field: 'queryType', value }); onChange({ field: 'queryType', value });
} }
@ -37,9 +174,11 @@ export const QueryType: React.FC<TQuerySelectField> = (props: TQuerySelectField)
name="queryType" name="queryType"
options={options} options={options}
aria-label={label} aria-label={label}
filterOption={filter}
onChange={handleChange} onChange={handleChange}
value={exportState(selections.queryType.value)} components={{ MenuList }}
isError={'queryType' in errors} 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 dynamic from 'next/dynamic';
import { Button, chakra, Stack, Text, VStack } from '@chakra-ui/react'; import { Button, chakra, Stack, Text, VStack } from '@chakra-ui/react';
import { useConfig, useColorValue } from '~/context'; 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( const RightArrow = chakra(
dynamic<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleRight)), 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)), 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 { function findAnswer(data: DnsOverHttps.Response | undefined): string {
let answer = ''; let answer = '';
if (typeof data !== 'undefined') { if (typeof data !== 'undefined') {
@ -24,18 +24,18 @@ function findAnswer(data: DnsOverHttps.Response | undefined): string {
return answer; return answer;
} }
export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget) => { export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => {
const { setTarget, errorClose } = props; const { errorClose } = props;
const strF = useStrf(); const strF = useStrf();
const { web } = useConfig(); 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 color = useColorValue('secondary.500', 'secondary.300');
const errorColor = useColorValue('red.500', 'red.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 tooltip4 = strF(web.text.fqdnTooltip, { protocol: 'IPv4' });
const tooltip6 = strF(web.text.fqdnTooltip, { protocol: 'IPv6' }); const tooltip6 = strF(web.text.fqdnTooltip, { protocol: 'IPv6' });
@ -47,14 +47,14 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
isLoading: isLoading4, isLoading: isLoading4,
isError: isError4, isError: isError4,
error: error4, error: error4,
} = useDNSQuery(displayTarget.value, 4); } = useDNSQuery(displayTarget, 4);
const { const {
data: data6, data: data6,
isLoading: isLoading6, isLoading: isLoading6,
isError: isError6, isError: isError6,
error: error6, error: error6,
} = useDNSQuery(displayTarget.value, 6); } = useDNSQuery(displayTarget, 6);
isError4 && console.error(error4); isError4 && console.error(error4);
isError6 && console.error(error6); isError6 && console.error(error6);
@ -62,39 +62,38 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
const answer4 = useMemo(() => findAnswer(data4), [data4]); const answer4 = useMemo(() => findAnswer(data4), [data4]);
const answer6 = useMemo(() => findAnswer(data6), [data6]); const answer6 = useMemo(() => findAnswer(data6), [data6]);
const handleOverride = useCallback(
(value: string): void => setTarget({ field: 'queryTarget', value }),
[setTarget],
);
function selectTarget(value: string): void { function selectTarget(value: string): void {
queryTarget.set(value); setFormValue('queryTarget', value);
isSubmitting.set(true); setStatus('results');
} }
useEffect(() => { const hasAnswer = useMemo(
if (query6 && data6?.Answer) { () => (!isError4 || !isError6) && (answer4 !== '' || answer6 !== ''),
handleOverride(findAnswer(data6)); [answer4, answer6, isError4, isError6],
} else if (query4 && data4?.Answer && !query6 && !data6?.Answer) { );
handleOverride(findAnswer(data4)); const showA = useMemo(
} else if (query4 && data4?.Answer) { () => !isLoading4 && !isError4 && answer4 !== '',
handleOverride(findAnswer(data4)); [isLoading4, isError4, answer4],
} );
}, [data4, data6, handleOverride, query4, query6]);
const showAAAA = useMemo(
() => !isLoading6 && !isError6 && answer6 !== '',
[isLoading6, isError6, answer6],
);
return ( return (
<VStack w="100%" spacing={4} justify="center"> <VStack w="100%" spacing={4} justify="center">
{(answer4 || answer6) && ( {hasAnswer && (
<Text fontSize="sm" textAlign="center"> <Text fontSize="sm" textAlign="center">
{messageStart} {messageStart}
<Text as="span" fontSize="sm" fontWeight="bold" color={color}> <Text as="span" fontSize="sm" fontWeight="bold" color={color}>
{`${displayTarget.value}`.toLowerCase()} {`${displayTarget}`.toLowerCase()}
</Text> </Text>
{messageEnd} {messageEnd}
</Text> </Text>
)} )}
<Stack spacing={2}> <Stack spacing={2}>
{!isLoading4 && !isError4 && query4 && answer4 && ( {showA && (
<Button <Button
size="sm" size="sm"
fontSize="xs" fontSize="xs"
@ -108,7 +107,7 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
{answer4} {answer4}
</Button> </Button>
)} )}
{!isLoading6 && !isError6 && query6 && answer6 && ( {showAAAA && (
<Button <Button
size="sm" size="sm"
fontSize="xs" fontSize="xs"
@ -122,18 +121,19 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
{answer6} {answer6}
</Button> </Button>
)} )}
{!answer4 && !answer6 && ( {!hasAnswer && (
<> <>
<Text fontSize="sm" textAlign="center" color={errorColor}> <Text fontSize="sm" textAlign="center" color={errorColor}>
{errorStart} {errorStart}
<Text as="span" fontSize="sm" fontWeight="bold"> <Text as="span" fontSize="sm" fontWeight="bold">
{`${displayTarget.value}`.toLowerCase()} {`${displayTarget}`.toLowerCase()}
</Text> </Text>
{errorEnd} {errorEnd}
</Text> </Text>
<Button <Button
colorScheme="red" colorScheme="red"
variant="outline" variant="outline"
size="sm"
onClick={errorClose} onClick={errorClose}
leftIcon={<LeftArrow />} leftIcon={<LeftArrow />}
> >

View file

@ -1,6 +1,6 @@
import type { FormControlProps } from '@chakra-ui/react'; import type { FormControlProps } from '@chakra-ui/react';
import type { UseFormRegister } from 'react-hook-form'; 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 { export interface TField extends FormControlProps {
name: string; name: string;
@ -17,10 +17,6 @@ export interface TQuerySelectField {
label: string; label: string;
} }
export interface TQueryGroup extends TQuerySelectField {
groups: string[];
}
export interface TQueryTarget { export interface TQueryTarget {
name: string; name: string;
placeholder: string; placeholder: string;
@ -28,7 +24,13 @@ export interface TQueryTarget {
onChange(e: OnChangeArgs): void; onChange(e: OnChangeArgs): void;
} }
export interface TResolvedTarget { export interface ResolvedTargetProps {
setTarget(e: OnChangeArgs): void;
errorClose(): void; 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) => { export const Greeting: React.FC<TGreeting> = (props: TGreeting) => {
const { web, content } = useConfig(); const { web, content } = useConfig();
const { ack: greetingAck, isOpen, close } = useGreeting(); const { isAck, isOpen, open, ack } = useGreeting();
const bg = useColorValue('white', 'gray.800'); const bg = useColorValue('white', 'gray.800');
const color = useOpposingColor(bg); 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(() => { useEffect(() => {
if (!greetingAck.value && web.greeting.enable) { if (!isAck && web.greeting.enable) {
isOpen.set(true); open();
} }
}, [greetingAck.value, isOpen, web.greeting.enable]); }, [isAck, open, web.greeting.enable]);
return ( return (
<Modal <Modal
size="lg" size="lg"
isCentered isCentered
onClose={handleClose} onClose={() => ack(false)}
isOpen={isOpen.value} isOpen={isOpen}
motionPreset="slideInBottom" motionPreset="slideInBottom"
closeOnEsc={web.greeting.required} closeOnEsc={web.greeting.required}
closeOnOverlayClick={web.greeting.required} closeOnOverlayClick={web.greeting.required}
@ -67,7 +54,7 @@ export const Greeting: React.FC<TGreeting> = (props: TGreeting) => {
<Markdown content={content.greeting} /> <Markdown content={content.greeting} />
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button colorScheme="primary" onClick={() => handleClose(true)}> <Button colorScheme="primary" onClick={() => ack(true)}>
{web.greeting.button} {web.greeting.button}
</Button> </Button>
</ModalFooter> </ModalFooter>

View file

@ -2,7 +2,7 @@ import { useRef } from 'react';
import { Flex, ScaleFade } from '@chakra-ui/react'; import { Flex, ScaleFade } from '@chakra-ui/react';
import { AnimatedDiv } from '~/components'; import { AnimatedDiv } from '~/components';
import { useBreakpointValue } from '~/context'; import { useBreakpointValue } from '~/context';
import { useBooleanValue, useLGState } from '~/hooks'; import { useBooleanValue, useFormState } from '~/hooks';
import { Title } from './title'; import { Title } from './title';
import type { THeader } from './types'; import type { THeader } from './types';
@ -10,12 +10,12 @@ import type { THeader } from './types';
export const Header: React.FC<THeader> = (props: THeader) => { export const Header: React.FC<THeader> = (props: THeader) => {
const { resetForm, ...rest } = props; const { resetForm, ...rest } = props;
const { isSubmitting } = useLGState(); const status = useFormState(s => s.status);
const titleRef = useRef({} as HTMLDivElement); const titleRef = useRef({} as HTMLDivElement);
const titleWidth = useBooleanValue( const titleWidth = useBooleanValue(
isSubmitting.value, status === 'results',
{ base: '75%', lg: '50%' }, { base: '75%', lg: '50%' },
{ base: '75%', lg: '75%' }, { base: '75%', lg: '75%' },
); );
@ -33,7 +33,7 @@ export const Header: React.FC<THeader> = (props: THeader) => {
maxW={titleWidth} maxW={titleWidth}
// This is here for the logo // This is here for the logo
justifyContent={justify} justifyContent={justify}
mx={{ base: isSubmitting.value ? 'auto' : 0, lg: 'auto' }} mx={{ base: status === 'results' ? 'auto' : 0, lg: 'auto' }}
> >
<Title /> <Title />
</AnimatedDiv> </AnimatedDiv>

View file

@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
import { isSafari } from 'react-device-detect'; import { isSafari } from 'react-device-detect';
import { If } from '~/components'; import { If } from '~/components';
import { useConfig, useMobile } from '~/context'; import { useConfig, useMobile } from '~/context';
import { useBooleanValue, useLGState, useLGMethods } from '~/hooks'; import { useBooleanValue, useFormState } from '~/hooks';
import { SubtitleOnly } from './subtitleOnly'; import { SubtitleOnly } from './subtitleOnly';
import { TitleOnly } from './titleOnly'; import { TitleOnly } from './titleOnly';
import { Logo } from './logo'; import { Logo } from './logo';
@ -16,12 +16,12 @@ const AnimatedVStack = motion(VStack);
* Title wrapper for mobile devices, breakpoints sm & md. * Title wrapper for mobile devices, breakpoints sm & md.
*/ */
const MWrapper: React.FC<TMWrapper> = (props: TMWrapper) => { const MWrapper: React.FC<TMWrapper> = (props: TMWrapper) => {
const { isSubmitting } = useLGState(); const status = useFormState(s => s.status);
return ( return (
<AnimatedVStack <AnimatedVStack
layout layout
spacing={1} spacing={1}
alignItems={isSubmitting.value ? 'center' : 'flex-start'} alignItems={status === 'results' ? 'center' : 'flex-start'}
{...props} {...props}
/> />
); );
@ -31,15 +31,15 @@ const MWrapper: React.FC<TMWrapper> = (props: TMWrapper) => {
* Title wrapper for desktop devices, breakpoints lg & xl. * Title wrapper for desktop devices, breakpoints lg & xl.
*/ */
const DWrapper: React.FC<TDWrapper> = (props: TDWrapper) => { const DWrapper: React.FC<TDWrapper> = (props: TDWrapper) => {
const { isSubmitting } = useLGState(); const status = useFormState(s => s.status);
return ( return (
<AnimatedVStack <AnimatedVStack
spacing={1} spacing={1}
initial="main" initial="main"
alignItems="center" alignItems="center"
animate={isSubmitting.value ? 'submitting' : 'main'} animate={status}
transition={{ damping: 15, type: 'spring', stiffness: 100 }} transition={{ damping: 15, type: 'spring', stiffness: 100 }}
variants={{ submitting: { scale: 0.5 }, main: { scale: 1 } }} variants={{ results: { scale: 0.5 }, form: { scale: 1 } }}
{...props} {...props}
/> />
); );
@ -108,15 +108,12 @@ export const Title: React.FC<TTitle> = (props: TTitle) => {
const { web } = useConfig(); const { web } = useConfig();
const { titleMode } = web.text; const { titleMode } = web.text;
const { isSubmitting } = useLGState(); const { status, reset } = useFormState(({ status, reset }) => ({
const { resetForm } = useLGMethods(); status,
reset,
}));
const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' }); const titleHeight = useBooleanValue(status === 'results', undefined, { md: '20vh' });
function handleClick(): void {
isSubmitting.set(false);
resetForm();
}
return ( return (
<Flex <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. 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%' }} flexBasis={{ base: '100%', lg: isSafari ? '33%' : '100%' }}
mt={[null, isSubmitting.value ? null : 'auto']} mt={{ md: status === 'results' ? undefined : 'auto' }}
{...rest} {...rest}
> >
<Button <Button
@ -140,7 +137,7 @@ export const Title: React.FC<TTitle> = (props: TTitle) => {
variant="link" variant="link"
flexWrap="wrap" flexWrap="wrap"
flexDir="column" flexDir="column"
onClick={handleClick} onClick={() => reset()}
_focus={{ boxShadow: 'none' }} _focus={{ boxShadow: 'none' }}
_hover={{ textDecoration: 'none' }} _hover={{ textDecoration: 'none' }}
> >

View file

@ -1,13 +1,12 @@
import { Heading } from '@chakra-ui/react'; import { Heading } from '@chakra-ui/react';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { useBooleanValue, useLGState } from '~/hooks'; import { useBooleanValue, useFormState } from '~/hooks';
import { useTitleSize } from './useTitleSize'; import { useTitleSize } from './useTitleSize';
export const TitleOnly: React.FC = () => { export const TitleOnly: React.FC = () => {
const { web } = useConfig(); const { web } = useConfig();
const { isSubmitting } = useLGState(); const status = useFormState(s => s.status);
const margin = useBooleanValue(status === 'results', 0, 2);
const margin = useBooleanValue(isSubmitting.value, 0, 2);
const sizeSm = useTitleSize(web.text.title, '2xl', []); const sizeSm = useTitleSize(web.text.title, '2xl', []);
return ( return (

View file

@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { Flex } from '@chakra-ui/react'; import { Flex } from '@chakra-ui/react';
import { isSafari } from 'react-device-detect'; import { isSafari } from 'react-device-detect';
import { If, Debugger, Greeting, Footer, Header } from '~/components'; import { If, Debugger, Greeting, Footer, Header } from '~/components';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { useLGState, useLGMethods, useGoogleAnalytics } from '~/hooks'; import { useGoogleAnalytics, useFormState } from '~/hooks';
import { ResetButton } from './resetButton'; import { ResetButton } from './resetButton';
import type { TFrame } from './types'; import type { TFrame } from './types';
@ -12,16 +12,18 @@ import type { TFrame } from './types';
export const Frame = (props: TFrame): JSX.Element => { export const Frame = (props: TFrame): JSX.Element => {
const router = useRouter(); const router = useRouter();
const { developerMode, googleAnalytics } = useConfig(); const { developerMode, googleAnalytics } = useConfig();
const { isSubmitting } = useLGState(); const { setStatus, reset } = useFormState(
const { resetForm } = useLGMethods(); useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []),
);
const { initialize, trackPage } = useGoogleAnalytics(); const { initialize, trackPage } = useGoogleAnalytics();
const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement); const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement);
function handleReset(): void { function handleReset(): void {
containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
isSubmitting.set(false); setStatus('form');
resetForm(); reset();
} }
useEffect(() => { useEffect(() => {
@ -48,7 +50,7 @@ export const Frame = (props: TFrame): JSX.Element => {
*/ */
minHeight={isSafari ? '-webkit-fill-available' : '100vh'} minHeight={isSafari ? '-webkit-fill-available' : '100vh'}
> >
<Header resetForm={handleReset} /> <Header resetForm={() => handleReset()} />
<Flex <Flex
px={4} px={4}
py={0} py={0}

View file

@ -1,14 +1,13 @@
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { LookingGlass, Results } from '~/components'; import { LookingGlass, Results } from '~/components';
import { useLGMethods } from '~/hooks'; import { useView } from '~/hooks';
import { Frame } from './frame'; import { Frame } from './frame';
export const Layout: React.FC = () => { export const Layout = (): JSX.Element => {
const { formReady } = useLGMethods(); const view = useView();
const ready = formReady();
return ( return (
<Frame> <Frame>
{ready ? ( {view === 'results' ? (
<Results /> <Results />
) : ( ) : (
<AnimatePresence> <AnimatePresence>

View file

@ -3,20 +3,20 @@ import { Flex, Icon, IconButton } from '@chakra-ui/react';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { AnimatedDiv } from '~/components'; import { AnimatedDiv } from '~/components';
import { useColorValue } from '~/context'; import { useColorValue } from '~/context';
import { useLGState, useOpposingColor } from '~/hooks'; import { useOpposingColor, useFormState } from '~/hooks';
import type { TResetButton } from './types'; import type { TResetButton } from './types';
const LeftArrow = dynamic<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaAngleLeft)); 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 { developerMode, resetForm, ...rest } = props;
const { isSubmitting } = useLGState(); const status = useFormState(s => s.status);
const bg = useColorValue('primary.500', 'primary.300'); const bg = useColorValue('primary.500', 'primary.300');
const color = useOpposingColor(bg); const color = useOpposingColor(bg);
return ( return (
<AnimatePresence> <AnimatePresence>
{isSubmitting.value && ( {status === 'results' && (
<AnimatedDiv <AnimatedDiv
bg={bg} bg={bg}
left={0} left={0}

View file

@ -1,14 +1,11 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import isEqual from 'react-fast-compare';
import { Flex, ScaleFade, SlideFade } from '@chakra-ui/react'; import { Flex, ScaleFade, SlideFade } from '@chakra-ui/react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { intersectionWith } from 'lodash';
import isEqual from 'react-fast-compare';
import { vestResolver } from '@hookform/resolvers/vest'; import { vestResolver } from '@hookform/resolvers/vest';
import vest, { test, enforce } from 'vest'; import vest, { test, enforce } from 'vest';
import { import {
If,
FormRow, FormRow,
QueryGroup,
FormField, FormField,
HelpModal, HelpModal,
QueryType, QueryType,
@ -18,8 +15,7 @@ import {
QueryLocation, QueryLocation,
} from '~/components'; } from '~/components';
import { useConfig } from '~/context'; import { useConfig } from '~/context';
import { useStrf, useGreeting, useDevice, useLGState, useLGMethods } from '~/hooks'; import { useStrf, useGreeting, useDevice, useFormState } from '~/hooks';
import { dedupObjectArray } from '~/util';
import { isString, isQueryField, Directive } from '~/types'; import { isString, isQueryField, Directive } from '~/types';
import type { FormData, OnChangeArgs } 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, /^(?!:\/\/)([a-zA-Z0-9-]+\.)?[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z-]{2,6}?$/im,
); );
function useIsFqdn(target: string, _type: string) { export const LookingGlass = (): JSX.Element => {
return useCallback(
(): boolean => ['bgp_route', 'ping', 'traceroute'].includes(_type) && fqdnPattern.test(target),
[target, _type],
);
}
export const LookingGlass: React.FC = () => {
const { web, messages } = useConfig(); const { web, messages } = useConfig();
const { ack, greetingReady } = useGreeting(); const greetingReady = useGreeting(s => s.greetingReady);
const getDevice = useDevice(); const getDevice = useDevice();
const strF = useStrf(); 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 noQueryType = strF(messages.noInput, { field: web.text.queryType });
const noQueryLoc = strF(messages.noInput, { field: web.text.queryLocation }); const noQueryLoc = strF(messages.noInput, { field: web.text.queryLocation });
const noQueryTarget = strF(messages.noInput, { field: web.text.queryTarget }); const noQueryTarget = strF(messages.noInput, { field: web.text.queryTarget });
const { const queryTypes = useMemo(() => filtered.types.map(t => t.id), [filtered.types]);
availableGroups,
queryType,
directive,
availableTypes,
btnLoading,
queryGroup,
queryTarget,
isSubmitting,
queryLocation,
displayTarget,
selections,
} = useLGState();
const queryTypes = useMemo(() => availableTypes.map(t => t.id.value), [availableTypes]);
const formSchema = vest.create((data: FormData = {} as FormData) => { const formSchema = vest.create((data: FormData = {} as FormData) => {
test('queryLocation', noQueryLoc, () => { test('queryLocation', noQueryLoc, () => {
@ -80,9 +69,6 @@ export const LookingGlass: React.FC = () => {
test('queryType', noQueryType, () => { test('queryType', noQueryType, () => {
enforce(data.queryType).inside(queryTypes); enforce(data.queryType).inside(queryTypes);
}); });
test('queryGroup', 'Query Group is empty', () => {
enforce(data.queryGroup).isString();
});
}); });
const formInstance = useForm<FormData>({ const formInstance = useForm<FormData>({
@ -91,155 +77,65 @@ export const LookingGlass: React.FC = () => {
queryTarget: '', queryTarget: '',
queryLocation: [], queryLocation: [],
queryType: '', queryType: '',
queryGroup: '',
}, },
}); });
const { handleSubmit, register, setValue, setError, clearErrors } = formInstance; 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 directive = useMemo<Directive | null>(
() => getDirective(),
const selectedDirective = useMemo(() => {
if (queryType.value === '') {
return null;
}
const directive = getDirective(queryType.value);
if (directive !== null) {
return directive;
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps // 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({ console.table({
'Query Location': queryLocation.value, 'Query Location': form.queryLocation.toString(),
'Query Type': queryType.value, 'Query Type': form.queryType,
'Query Group': queryGroup.value, 'Query Target': form.queryTarget,
'Query Target': queryTarget.value, 'Selected Directive': directive?.name ?? null,
'Selected Directive': selectedDirective?.name ?? null,
}); });
/** // Before submitting a query, make sure the greeting is acknowledged if required. This should
* Before submitting a query, make sure the greeting is acknowledged if required. This should // be handled before loading the app, but people be sneaky.
* be handled before loading the app, but people be sneaky.
*/ if (!greetingReady) {
if (!greetingReady()) {
resetForm(); resetForm();
location.reload(); location.reload();
return;
} }
// Determine if queryTarget is an FQDN. // Determine if queryTarget is an FQDN.
const isFqdn = isFqdnQuery(); const isFqdn = isFqdnQuery(form.queryTarget, directive?.fieldType ?? null);
if (greetingReady() && !isFqdn) { if (greetingReady && !isFqdn) {
return isSubmitting.set(true); return setStatus('results');
} }
if (greetingReady() && isFqdn) { if (greetingReady && isFqdn) {
btnLoading.set(true); setLoading(true);
return resolvedOpen(); return resolvedOpen();
} else { } else {
console.group('%cSomething went wrong', 'color:red;'); console.group('%cSomething went wrong', 'color:red;');
console.table({ console.table({
'Greeting Required': web.greeting.required, 'Greeting Required': web.greeting.required,
'Greeting Ready': greetingReady(), 'Greeting Ready': greetingReady,
'Greeting Acknowledged': ack.value, 'Query Target': form.queryTarget,
'Query Target': queryTarget.value, 'Query Type': form.queryType,
'Query Type': queryType.value,
'Is FQDN': isFqdn, 'Is FQDN': isFqdn,
}); });
console.groupEnd(); console.groupEnd();
} }
} }
function handleLocChange(locations: string[]): void { const handleLocChange = (locations: string[]) =>
clearErrors('queryLocation'); locationChange(locations, { setError, clearErrors, getDevice, text: web.text });
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);
}
}
function handleChange(e: OnChangeArgs): void { function handleChange(e: OnChangeArgs): void {
// Signal the field & value to react-hook-form. // 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)) { if (e.field === 'queryLocation' && Array.isArray(e.value)) {
handleLocChange(e.value); handleLocChange(e.value);
} else if (e.field === 'queryType' && isString(e.value)) { } else if (e.field === 'queryType' && isString(e.value)) {
queryType.set(e.value); setValue('queryType', e.value);
if (queryTarget.value !== '') { setFormValue('queryType', e.value);
if (form.queryTarget !== '') {
// Reset queryTarget as well, so that, for example, selecting BGP Community, and selecting // 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 // a community, then changing the queryType to BGP Route doesn't preserve the selected
// community as the queryTarget. // community as the queryTarget.
queryTarget.set(''); setFormValue('queryTarget', '');
displayTarget.set(''); setTarget({ display: '' });
} }
} else if (e.field === 'queryTarget' && isString(e.value)) { } else if (e.field === 'queryTarget' && isString(e.value)) {
queryTarget.set(e.value); setFormValue('queryTarget', e.value);
} else if (e.field === 'queryGroup' && isString(e.value)) {
// queryGroup.set(e.value);
handleGroupChange(e.value);
} }
} }
useEffect(() => { useEffect(() => {
register('queryLocation', { required: true }); register('queryLocation', { required: true });
// register('queryTarget', { required: true });
register('queryType', { required: true }); register('queryType', { required: true });
register('queryGroup');
}, [register]); }, [register]);
return ( return (
@ -295,25 +187,16 @@ export const LookingGlass: React.FC = () => {
<FormField name="queryLocation" label={web.text.queryLocation}> <FormField name="queryLocation" label={web.text.queryLocation}>
<QueryLocation onChange={handleChange} label={web.text.queryLocation} /> <QueryLocation onChange={handleChange} label={web.text.queryLocation} />
</FormField> </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>
<FormRow> <FormRow>
<SlideFade offsetX={-100} in={availableTypes.length > 1} unmountOnExit> <SlideFade offsetY={100} in={filtered.types.length > 0} unmountOnExit>
<FormField <FormField
name="queryType" name="queryType"
label={web.text.queryType} label={web.text.queryType}
labelAddOn={ labelAddOn={
<HelpModal <HelpModal
visible={selectedDirective?.info.value !== null} visible={directive?.info !== null}
item={selectedDirective?.info.value ?? null} item={directive?.info ?? null}
name="queryType" name="queryType"
/> />
} }
@ -321,14 +204,14 @@ export const LookingGlass: React.FC = () => {
<QueryType onChange={handleChange} label={web.text.queryType} /> <QueryType onChange={handleChange} label={web.text.queryType} />
</FormField> </FormField>
</SlideFade> </SlideFade>
<SlideFade offsetX={100} in={selectedDirective !== null} unmountOnExit> <SlideFade offsetX={100} in={directive !== null} unmountOnExit>
{selectedDirective !== null && ( {directive !== null && (
<FormField name="queryTarget" label={web.text.queryTarget}> <FormField name="queryTarget" label={web.text.queryTarget}>
<QueryTarget <QueryTarget
name="queryTarget" name="queryTarget"
register={register} register={register}
onChange={handleChange} onChange={handleChange}
placeholder={selectedDirective.description.value} placeholder={directive.description}
/> />
</FormField> </FormField>
)} )}
@ -344,8 +227,8 @@ export const LookingGlass: React.FC = () => {
flexDir="column" flexDir="column"
mr={{ base: 0, lg: 2 }} mr={{ base: 0, lg: 2 }}
> >
<ScaleFade initialScale={0.5} in={queryTarget.value !== ''}> <ScaleFade initialScale={0.5} in={form.queryTarget !== ''}>
<SubmitButton handleChange={handleChange} /> <SubmitButton />
</ScaleFade> </ScaleFade>
</Flex> </Flex>
</FormRow> </FormRow>

View file

@ -9,7 +9,7 @@ import {
ModalCloseButton, ModalCloseButton,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useColorValue, useBreakpointValue } from '~/context'; import { useColorValue, useBreakpointValue } from '~/context';
import { useLGState, useLGMethods } from '~/hooks'; import { useFormState } from '~/hooks';
import { PathButton } from './button'; import { PathButton } from './button';
import { Chart } from './chart'; import { Chart } from './chart';
@ -17,8 +17,8 @@ import type { TPath } from './types';
export const Path: React.FC<TPath> = (props: TPath) => { export const Path: React.FC<TPath> = (props: TPath) => {
const { device } = props; const { device } = props;
const { displayTarget } = useLGState(); const displayTarget = useFormState(s => s.target.display);
const { getResponse } = useLGMethods(); const getResponse = useFormState(s => s.response);
const { isOpen, onClose, onOpen } = useDisclosure(); const { isOpen, onClose, onOpen } = useDisclosure();
const response = getResponse(device); const response = getResponse(device);
const output = response?.output as StructuredResponse; const output = response?.output as StructuredResponse;
@ -35,7 +35,7 @@ export const Path: React.FC<TPath> = (props: TPath) => {
maxH={{ base: '80%', lg: '60%' }} maxH={{ base: '80%', lg: '60%' }}
maxW={{ base: '100%', lg: '80%' }} maxW={{ base: '100%', lg: '80%' }}
> >
<ModalHeader>{`Path to ${displayTarget.value}`}</ModalHeader> <ModalHeader>{`Path to ${displayTarget}`}</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody> <ModalBody>
{response !== null ? <Chart data={output} /> : <Skeleton w="500px" h="300px" />} {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 { Accordion } from '@chakra-ui/react';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import { AnimatedDiv } from '~/components'; import { AnimatedDiv } from '~/components';
import { useDevice, useLGState } from '~/hooks'; import { useFormState } from '~/hooks';
import { Result } from './individual'; import { Result } from './individual';
import { Tags } from './tags'; import { Tags } from './tags';
export const Results: React.FC = () => { export const Results: React.FC = () => {
const { queryLocation, queryTarget, queryType, queryGroup } = useLGState(); const { queryLocation } = useFormState(s => s.form);
const getDevice = useDevice();
// Scroll to the top of the page when results load - primarily for mobile. // Scroll to the top of the page when results load - primarily for mobile.
useEffect(() => { useEffect(() => {
@ -38,20 +36,9 @@ export const Results: React.FC = () => {
> >
<Accordion allowMultiple allowToggle> <Accordion allowMultiple allowToggle>
<AnimatePresence> <AnimatePresence>
{queryLocation.value && {queryLocation.length > 0 &&
queryLocation.map((loc, i) => { queryLocation.map((location, index) => {
const device = getDevice(loc.value); return <Result index={index} key={location} queryLocation={location} />;
return (
<Result
index={i}
device={device}
key={device.id}
queryLocation={loc.value}
queryType={queryType.value}
queryGroup={queryGroup.value}
queryTarget={queryTarget.value}
/>
);
})} })}
</AnimatePresence> </AnimatePresence>
</Accordion> </Accordion>

View file

@ -1,23 +1,24 @@
import { forwardRef, useEffect, useMemo } from 'react'; import { forwardRef, memo, useEffect, useMemo } from 'react';
import { import {
Box, Box,
Flex, Flex,
chakra,
Icon, Icon,
Alert, Alert,
chakra,
HStack, HStack,
Tooltip, Tooltip,
AccordionItem, AccordionItem,
AccordionPanel, AccordionPanel,
useAccordionContext,
AccordionButton, AccordionButton,
useAccordionContext,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { BsLightningFill } from '@meronex/icons/bs'; import { BsLightningFill } from '@meronex/icons/bs';
import { startCase } from 'lodash'; import { startCase } from 'lodash';
import isEqual from 'react-fast-compare';
import { BGPTable, Countdown, TextOutput, If, Path } from '~/components'; import { BGPTable, Countdown, TextOutput, If, Path } from '~/components';
import { useColorValue, useConfig, useMobile } from '~/context'; 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 { isStructuredOutput, isStringOutput } from '~/types';
import { isStackError, isFetchError, isLGError, isLGOutputOrError } from './guards'; import { isStackError, isFetchError, isLGError, isLGOutputOrError } from './guards';
import { RequeryButton } from './requeryButton'; import { RequeryButton } from './requeryButton';
@ -25,7 +26,7 @@ import { CopyButton } from './copyButton';
import { FormattedError } from './error'; import { FormattedError } from './error';
import { ResultHeader } from './header'; import { ResultHeader } from './header';
import type { TResult, TErrorLevels } from './types'; import type { ResultProps, TErrorLevels } from './types';
const AnimatedAccordionItem = motion(AccordionItem); const AnimatedAccordionItem = motion(AccordionItem);
@ -38,11 +39,15 @@ const AccordionHeaderWrapper = chakra('div', {
}, },
}); });
const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: TResult, ref) => { const _Result: React.ForwardRefRenderFunction<HTMLDivElement, ResultProps> = (
const { index, device, queryType, queryTarget, queryLocation, queryGroup } = props; props: ResultProps,
ref,
) => {
const { index, queryLocation } = props;
const { web, cache, messages } = useConfig(); const { web, cache, messages } = useConfig();
const { index: indices, setIndex } = useAccordionContext(); const { index: indices, setIndex } = useAccordionContext();
const getDevice = useDevice();
const device = getDevice(queryLocation);
const isMobile = useMobile(); const isMobile = useMobile();
const color = useColorValue('black', 'white'); const color = useColorValue('black', 'white');
@ -50,20 +55,26 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400'); const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400');
const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50'); const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50');
const { responses } = useLGState(); const addResponse = useFormState(s => s.addResponse);
const form = useFormState(s => s.form);
const { data, error, isError, isLoading, refetch, isFetchedAfterMount } = useLGQuery({
queryLocation,
queryTarget,
queryType,
queryGroup,
});
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]); const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [data, isFetchedAfterMount]);
if (typeof data !== 'undefined') {
responses.merge({ [device.id]: data });
}
const strF = useStrf(); const strF = useStrf();
const cacheLabel = strF(web.text.cacheIcon, { time: data?.timestamp }); 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]); }, [error, data, messages.general, messages.requestTimeout]);
isError && console.error(error);
const errorLevel = useMemo<TErrorLevels>(() => { const errorLevel = useMemo<TErrorLevels>(() => {
const statusMap = { const statusMap = {
success: 'success', success: 'success',
@ -113,15 +122,15 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
const tableComponent = useMemo<boolean>(() => { const tableComponent = useMemo<boolean>(() => {
let result = false; let result = false;
if (typeof queryType.match(/^bgp_\w+$/) !== null && data?.format === 'application/json') { if (data?.format === 'application/json') {
result = true; result = true;
} }
return result; return result;
}, [queryType, data?.format]); }, [data?.format]);
let copyValue = data?.output as string; 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') { if (data?.format === 'application/json') {
copyValue = formatData(); copyValue = formatData();
@ -141,7 +150,6 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
} }
} }
}, [data, index, indices, isLoading, isError, setIndex]); }, [data, index, indices, isLoading, isError, setIndex]);
return ( return (
<AnimatedAccordionItem <AnimatedAccordionItem
ref={ref} 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 { motion, AnimatePresence } from 'framer-motion';
import { Label } from '~/components'; import { Label } from '~/components';
import { useConfig, useBreakpointValue } from '~/context'; import { useConfig, useBreakpointValue } from '~/context';
import { useLGState, useLGMethods } from '~/hooks'; import { useFormState } from '~/hooks';
import type { Transition } from 'framer-motion'; 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 = () => { export const Tags: React.FC = () => {
const { web } = useConfig(); const { web } = useConfig();
const { queryLocation, queryTarget, queryType, queryGroup } = useLGState(); const form = useFormState(s => s.form);
const { getDirective } = useLGMethods(); const getDirective = useFormState(s => s.getDirective);
const selectedDirective = useMemo(() => { const selectedDirective = useMemo(() => {
if (queryType.value === '') { return getDirective();
return null;
}
const directive = getDirective(queryType.value);
if (directive !== null) {
return directive;
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryType.value, queryGroup.value, getDirective]); }, [form.queryType, getDirective]);
const targetBg = useToken('colors', 'teal.600'); const targetBg = useToken('colors', 'teal.600');
const queryBg = useToken('colors', 'cyan.500'); const queryBg = useToken('colors', 'cyan.500');
const vrfBg = useToken('colors', 'blue.500');
const animateLeft = useBreakpointValue({ const animateLeft = useBreakpointValue({
base: { opacity: 1, x: 0 }, base: { opacity: 1, x: 0 },
@ -37,12 +29,12 @@ export const Tags: React.FC = () => {
xl: { opacity: 1, x: 0 }, xl: { opacity: 1, x: 0 },
}); });
const animateCenter = useBreakpointValue({ // const animateCenter = useBreakpointValue({
base: { opacity: 1 }, // base: { opacity: 1 },
md: { opacity: 1 }, // md: { opacity: 1 },
lg: { opacity: 1 }, // lg: { opacity: 1 },
xl: { opacity: 1 }, // xl: { opacity: 1 },
}); // });
const animateRight = useBreakpointValue({ const animateRight = useBreakpointValue({
base: { opacity: 1, x: 0 }, base: { opacity: 1, x: 0 },
@ -58,12 +50,12 @@ export const Tags: React.FC = () => {
xl: { opacity: 0, x: '-100%' }, xl: { opacity: 0, x: '-100%' },
}); });
const initialCenter = useBreakpointValue({ // const initialCenter = useBreakpointValue({
base: { opacity: 0 }, // base: { opacity: 0 },
md: { opacity: 0 }, // md: { opacity: 0 },
lg: { opacity: 0 }, // lg: { opacity: 0 },
xl: { opacity: 0 }, // xl: { opacity: 0 },
}); // });
const initialRight = useBreakpointValue({ const initialRight = useBreakpointValue({
base: { opacity: 0, x: '100%' }, base: { opacity: 0, x: '100%' },
@ -83,7 +75,7 @@ export const Tags: React.FC = () => {
> >
<Stack isInline align="center" justify="center" mt={4} flexWrap="wrap"> <Stack isInline align="center" justify="center" mt={4} flexWrap="wrap">
<AnimatePresence> <AnimatePresence>
{queryLocation.value && ( {form.queryLocation.length > 0 && (
<> <>
<motion.div <motion.div
initial={initialLeft} initial={initialLeft}
@ -95,32 +87,19 @@ export const Tags: React.FC = () => {
bg={queryBg} bg={queryBg}
label={web.text.queryType} label={web.text.queryType}
fontSize={{ base: 'xs', md: 'sm' }} fontSize={{ base: 'xs', md: 'sm' }}
value={selectedDirective?.value.name ?? 'None'} value={selectedDirective?.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' }}
/> />
</motion.div> </motion.div>
<motion.div <motion.div
initial={initialRight} initial={initialRight}
animate={animateRight} animate={animateRight}
exit={{ opacity: 0, x: '100%' }} exit={{ opacity: 0, scale: 0.5 }}
transition={transition} transition={transition}
> >
<Label <Label
bg={vrfBg} bg={targetBg}
label={web.text.queryGroup} value={form.queryTarget}
value={queryGroup.value} label={web.text.queryTarget}
fontSize={{ base: 'xs', md: 'sm' }} fontSize={{ base: 'xs', md: 'sm' }}
/> />
</motion.div> </motion.div>

View file

@ -1,7 +1,5 @@
import type { State } from '@hookstate/core';
import type { ButtonProps } from '@chakra-ui/react'; import type { ButtonProps } from '@chakra-ui/react';
import type { UseQueryResult } from 'react-query'; import type { UseQueryResult } from 'react-query';
import type { Device } from '~/types';
export interface TResultHeader { export interface TResultHeader {
title: string; title: string;
@ -17,13 +15,9 @@ export interface TFormattedError {
message: string; message: string;
} }
export interface TResult { export interface ResultProps {
index: number; index: number;
device: Device;
queryGroup: string;
queryTarget: string;
queryLocation: string; queryLocation: string;
queryType: string;
} }
export type TErrorLevels = 'success' | 'warning' | 'error'; export type TErrorLevels = 'success' | 'warning' | 'error';
@ -35,18 +29,3 @@ export interface TCopyButton extends ButtonProps {
export interface TRequeryButton extends ButtonProps { export interface TRequeryButton extends ButtonProps {
requery: UseQueryResult<QueryResponse>['refetch']; 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 * 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 ReactSelect from 'react-select';
import { chakra, useDisclosure } from '@chakra-ui/react'; import { chakra, useDisclosure } from '@chakra-ui/react';
import { useColorMode } from '~/context'; import { useColorMode } from '~/context';
import { Option } from './option';
import { import {
useRSTheme, useRSTheme,
useMenuStyle, useMenuStyle,
@ -17,16 +18,16 @@ import {
useIndicatorSeparatorStyle, useIndicatorSeparatorStyle,
} from './styles'; } from './styles';
import type { TSelectOption } from '~/types'; import type { SingleOption } from '~/types';
import type { TSelectBase, TSelectContext, TReactSelectChakra } 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); export const useSelectContext = (): TSelectContext => useContext(SelectContext);
const ReactSelectChakra = chakra<typeof ReactSelect, TReactSelectChakra>(ReactSelect); const ReactSelectChakra = chakra<typeof ReactSelect, TReactSelectChakra>(ReactSelect);
export const Select: React.FC<TSelectBase> = (props: TSelectBase) => { 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 { isOpen, onOpen, onClose } = useDisclosure();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
@ -36,7 +37,7 @@ export const Select: React.FC<TSelectBase> = (props: TSelectBase) => {
[colorMode, isError, isOpen], [colorMode, isError, isOpen],
); );
const defaultOnChange = (changed: TSelectOption | TSelectOption[]) => { const defaultOnChange = (changed: SingleOption | SingleOption[]) => {
if (!Array.isArray(changed)) { if (!Array.isArray(changed)) {
changed = [changed]; changed = [changed];
} }
@ -61,6 +62,7 @@ export const Select: React.FC<TSelectBase> = (props: TSelectBase) => {
options={options} options={options}
isMulti={multi} isMulti={multi}
theme={rsTheme} theme={rsTheme}
components={{ Option, ...components }}
styles={{ styles={{
menuPortal, menuPortal,
multiValue, multiValue,

View file

@ -14,13 +14,13 @@ import type {
Styles as RSStyles, Styles as RSStyles,
} from 'react-select'; } from 'react-select';
import type { BoxProps } from '@chakra-ui/react'; 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 { export interface TSelectState {
[k: string]: string[]; [k: string]: string[];
} }
export type TOptions = Array<TSelectOptionGroup | TSelectOption>; export type TOptions = Array<SingleOption | OptionGroup>;
export type TReactSelectChakra = Omit<IReactSelect, 'isMulti' | 'onSelect' | 'onChange'> & export type TReactSelectChakra = Omit<IReactSelect, 'isMulti' | 'onSelect' | 'onChange'> &
Omit<BoxProps, 'onChange' | 'onSelect'>; Omit<BoxProps, 'onChange' | 'onSelect'>;
@ -31,8 +31,8 @@ export interface TSelectBase extends TReactSelectChakra {
isError?: boolean; isError?: boolean;
options: TOptions; options: TOptions;
required?: boolean; required?: boolean;
onSelect?: (s: TSelectOption[]) => void; onSelect?: (s: SingleOption[]) => void;
onChange?: (c: TSelectOption | TSelectOptionMulti) => void; onChange?: (c: SingleOption | SingleOption[]) => void;
colorScheme?: Theme.ColorNames; colorScheme?: Theme.ColorNames;
} }

View file

@ -17,10 +17,10 @@ import { FiSearch } from '@meronex/icons/fi';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { If, ResolvedTarget } from '~/components'; import { If, ResolvedTarget } from '~/components';
import { useMobile, useColorValue } from '~/context'; import { useMobile, useColorValue } from '~/context';
import { useLGState, useLGMethods } from '~/hooks'; import { useFormState } from '~/hooks';
import type { IconButtonProps } from '@chakra-ui/react'; import type { IconButtonProps } from '@chakra-ui/react';
import type { TSubmitButton, TRSubmitButton } from './types'; import type { SubmitButtonProps, ResponsiveSubmitButtonProps } from './types';
const _SubmitIcon: React.ForwardRefRenderFunction< const _SubmitIcon: React.ForwardRefRenderFunction<
HTMLButtonElement, HTMLButtonElement,
@ -47,8 +47,8 @@ const SubmitIcon = forwardRef<HTMLButtonElement, Omit<IconButtonProps, 'aria-lab
/** /**
* Mobile Submit Button * Mobile Submit Button
*/ */
const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => { const MSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => {
const { children, isOpen, onClose, onChange } = props; const { children, isOpen, onClose } = props;
const bg = useColorValue('white', 'gray.900'); const bg = useColorValue('white', 'gray.900');
return ( return (
<> <>
@ -66,7 +66,7 @@ const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
<ModalContent bg={bg}> <ModalContent bg={bg}>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody px={4} py={10}> <ModalBody px={4} py={10}>
{isOpen && <ResolvedTarget setTarget={onChange} errorClose={onClose} />} {isOpen && <ResolvedTarget errorClose={onClose} />}
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</Modal> </Modal>
@ -77,8 +77,8 @@ const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
/** /**
* Desktop Submit Button * Desktop Submit Button
*/ */
const DSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => { const DSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => {
const { children, isOpen, onClose, onChange } = props; const { children, isOpen, onClose } = props;
const bg = useColorValue('white', 'gray.900'); const bg = useColorValue('white', 'gray.900');
return ( return (
<Popover isOpen={isOpen} onClose={onClose} closeOnBlur={false}> <Popover isOpen={isOpen} onClose={onClose} closeOnBlur={false}>
@ -86,19 +86,24 @@ const DSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
<PopoverContent bg={bg}> <PopoverContent bg={bg}>
<PopoverArrow bg={bg} /> <PopoverArrow bg={bg} />
<PopoverCloseButton /> <PopoverCloseButton />
<PopoverBody p={6}> <PopoverBody p={6}>{isOpen && <ResolvedTarget errorClose={onClose} />}</PopoverBody>
{isOpen && <ResolvedTarget setTarget={onChange} errorClose={onClose} />}
</PopoverBody>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}; };
export const SubmitButton: React.FC<TSubmitButton> = (props: TSubmitButton) => { export const SubmitButton = (props: SubmitButtonProps): JSX.Element => {
const { handleChange } = props;
const isMobile = useMobile(); const isMobile = useMobile();
const { resolvedIsOpen, btnLoading } = useLGState(); const loading = useFormState(s => s.loading);
const { resolvedClose, resetForm } = useLGMethods(); const {
resolvedIsOpen,
resolvedClose,
reset: resetForm,
} = useFormState(({ resolvedIsOpen, resolvedClose, reset }) => ({
resolvedIsOpen,
resolvedClose,
reset,
}));
const { reset } = useFormContext(); const { reset } = useFormContext();
@ -111,13 +116,13 @@ export const SubmitButton: React.FC<TSubmitButton> = (props: TSubmitButton) => {
return ( return (
<> <>
<If c={isMobile}> <If c={isMobile}>
<MSubmitButton isOpen={resolvedIsOpen.value} onClose={handleClose} onChange={handleChange}> <MSubmitButton isOpen={resolvedIsOpen} onClose={handleClose}>
<SubmitIcon isLoading={btnLoading.value} /> <SubmitIcon isLoading={loading} {...props} />
</MSubmitButton> </MSubmitButton>
</If> </If>
<If c={!isMobile}> <If c={!isMobile}>
<DSubmitButton isOpen={resolvedIsOpen.value} onClose={handleClose} onChange={handleChange}> <DSubmitButton isOpen={resolvedIsOpen} onClose={handleClose}>
<SubmitIcon isLoading={btnLoading.value} /> <SubmitIcon isLoading={loading} {...props} />
</DSubmitButton> </DSubmitButton>
</If> </If>
</> </>

View file

@ -1,13 +1,9 @@
import type { IconButtonProps } from '@chakra-ui/react'; import type { IconButtonProps } from '@chakra-ui/react';
import type { OnChangeArgs } from '~/types';
export interface TSubmitButton extends Omit<IconButtonProps, 'aria-label'> { export type SubmitButtonProps = Omit<IconButtonProps, 'aria-label'>;
handleChange(e: OnChangeArgs): void;
}
export interface TRSubmitButton { export interface ResponsiveSubmitButtonProps {
isOpen: boolean; isOpen: boolean;
onClose(): void; onClose(): void;
onChange(e: OnChangeArgs): void;
children: React.ReactNode; children: React.ReactNode;
} }

View file

@ -1,8 +1,14 @@
/* eslint react/display-name: off */ import { chakra, Box, forwardRef } from '@chakra-ui/react';
import { Box, forwardRef } from '@chakra-ui/react';
import { motion, isValidMotionProp } from 'framer-motion'; import { motion, isValidMotionProp } from 'framer-motion';
import type { BoxProps } from '@chakra-ui/react'; 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. * Combined Chakra + Framer Motion component.
@ -16,3 +22,18 @@ export const AnimatedDiv = motion(
return <Box ref={ref} {...chakraProps} />; 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 } from '~/types';
import type { Config, FormData } from '~/types';
export interface THyperglassProvider { export interface THyperglassProvider {
config: Config; config: Config;
children: React.ReactNode; 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 './useDevice';
export * from './useDirective'; export * from './useDirective';
export * from './useDNSQuery'; export * from './useDNSQuery';
export * from './useFormState';
export * from './useGoogleAnalytics'; export * from './useGoogleAnalytics';
export * from './useGreeting'; export * from './useGreeting';
export * from './useHyperglassConfig'; export * from './useHyperglassConfig';
export * from './useLGQuery'; export * from './useLGQuery';
export * from './useLGState';
export * from './useOpposingColor'; export * from './useOpposingColor';
export * from './useStrf'; export * from './useStrf';
export * from './useTableToString'; 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 * 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 LGQueryKey = [string, TFormQuery];
export type DNSQueryKey = [string, { target: string | null; family: 4 | 6 }]; 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 { export interface TOpposingOptions {
light?: string; light?: string;
dark?: string; dark?: string;
} }
export type TUseGreetingReturn = { export interface UseGreeting {
ack: State<boolean>; isAck: boolean;
isOpen: State<boolean>; isOpen: boolean;
greetingReady: boolean;
ack(value: boolean): void;
open(): void; open(): void;
close(): void; close(): void;
greetingReady(): boolean; }
};
export type TUseDevice = ( export type TUseDevice = (
/** /**
@ -25,50 +36,6 @@ export type TUseDevice = (
deviceId: string, deviceId: string,
) => Device; ) => 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 UseStrfArgs = { [k: string]: unknown } | string;
export type TTableToStringFormatter = export type TTableToStringFormatter =

View file

@ -1,21 +1,18 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useLGMethods, useLGState } from './useLGState'; import { useFormState } from './useFormState';
import type { Directive } from '~/types'; import type { Directive } from '~/types';
export function useDirective(): Nullable<Directive> { export function useDirective(): Nullable<Directive> {
const { queryType, queryGroup } = useLGState(); const { getDirective, form } = useFormState(({ getDirective, form }) => ({ getDirective, form }));
const { getDirective } = useLGMethods();
return useMemo((): Nullable<Directive> => { return useMemo((): Nullable<Directive> => {
if (queryType.value === '') { if (form.queryType === '') {
return null; return null;
} }
const directive = getDirective(queryType.value); const directive = getDirective();
if (directive !== null) { return directive;
return directive.value;
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 { useCallback } from 'react';
import { createState, useState } from '@hookstate/core';
import * as ReactGA from 'react-ga'; import * as ReactGA from 'react-ga';
import type { GAEffect, GAReturn } from './types'; 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 { export function useGoogleAnalytics(): GAReturn {
const enabled = useState<boolean>(enabledState); const { enabled, enable } = useEnabled(({ enable, enabled }) => ({ enable, enabled }));
const runEffect = useCallback( const runEffect = useCallback(
(effect: GAEffect): void => { (effect: GAEffect): void => {
if (typeof window !== 'undefined' && enabled.value) { if (typeof window !== 'undefined' && enabled) {
if (typeof effect === 'function') { if (typeof effect === 'function') {
effect(ReactGA); effect(ReactGA);
} }
} }
}, },
[enabled.value], [enabled],
); );
const trackEvent = useCallback( const trackEvent = useCallback(
@ -77,7 +91,7 @@ export function useGoogleAnalytics(): GAReturn {
return; return;
} }
enabled.set(true); enable();
const initializeOpts = { titleCase: false } as ReactGA.InitializeOptions; const initializeOpts = { titleCase: false } as ReactGA.InitializeOptions;
@ -89,7 +103,7 @@ export function useGoogleAnalytics(): GAReturn {
ga.initialize(trackingId, initializeOpts); ga.initialize(trackingId, initializeOpts);
}); });
}, },
[runEffect, enabled], [runEffect, enable],
); );
return { trackEvent, trackModal, trackPage, initialize, ga: ReactGA }; return { trackEvent, trackModal, trackPage, initialize, ga: ReactGA };

View file

@ -1,45 +1,54 @@
import { createState, useState } from '@hookstate/core'; import create from 'zustand';
import { Persistence } from '@hookstate/persistence'; import { persist } from 'zustand/middleware';
import { useConfig } from '~/context'; 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); export function useGreeting(): UseGreeting;
const openState = createState<boolean>(false); export function useGreeting<U extends ValueOf<UseGreeting>>(
selector: StateSelector<UseGreeting, U>,
/** equalityFn?: EqualityChecker<U>,
* Hook to manage the greeting, a.k.a. the popup at config path web.greeting. ): U;
*/ export function useGreeting<U extends Partial<UseGreeting>>(
export function useGreeting(): TUseGreetingReturn { selector: StateSelector<UseGreeting, U>,
const ack = useState<boolean>(ackState); equalityFn?: EqualityChecker<U>,
const isOpen = useState<boolean>(openState); ): U;
const { web } = useConfig(); export function useGreeting<U extends UseGreeting>(
selector?: StateSelector<UseGreeting, U>,
if (typeof window !== 'undefined') { equalityFn?: EqualityChecker<U>,
ack.attach(Persistence('hyperglass-greeting')); ): 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);
} }
return storeFn() as U;
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 };
} }

View file

@ -6,12 +6,15 @@ import { fetchWithTimeout } from '~/util';
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query'; import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
import type { TFormQuery } from '~/types'; 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. * 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 { requestTimeout, cache } = useConfig();
const controller = useMemo(() => new AbortController(), []); const controller = useMemo(() => new AbortController(), []);
@ -23,14 +26,13 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse
dimension1: query.queryLocation, dimension1: query.queryLocation,
dimension2: query.queryTarget, dimension2: query.queryTarget,
dimension3: query.queryType, dimension3: query.queryType,
dimension4: query.queryGroup,
}); });
const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async ( const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async (
ctx: QueryFunctionContext<LGQueryKey>, ctx: QueryFunctionContext<LGQueryKey>,
): Promise<QueryResponse> => { ): Promise<QueryResponse> => {
const [url, data] = ctx.queryKey; const [url, data] = ctx.queryKey;
const { queryLocation, queryTarget, queryType, queryGroup } = data; const { queryLocation, queryTarget, queryType } = data;
const res = await fetchWithTimeout( const res = await fetchWithTimeout(
url, url,
{ {
@ -40,7 +42,6 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse
queryLocation, queryLocation,
queryTarget, queryTarget,
queryType, queryType,
queryGroup,
}), }),
mode: 'cors', mode: 'cors',
}, },
@ -73,5 +74,6 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse
refetchInterval: false, refetchInterval: false,
// Don't refetch on component remount. // Don't refetch on component remount.
refetchOnMount: false, 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", "browserslist": "> 0.25%, not dead",
"dependencies": { "dependencies": {
"@chakra-ui/react": "^1.6.3", "@chakra-ui/react": "^1.6.3",
"@choc-ui/chakra-autocomplete": "^4.5.10",
"@emotion/react": "^11.4.0", "@emotion/react": "^11.4.0",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@hookform/devtools": "^3.1.0", "@hookform/devtools": "^3.1.0",
"@hookform/resolvers": "^2.5.1", "@hookform/resolvers": "^2.5.1",
"@hookstate/core": "^3.0.7",
"@hookstate/persistence": "^3.0.0",
"@meronex/icons": "^4.0.0", "@meronex/icons": "^4.0.0",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
@ -29,6 +28,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"next": "^11.1.2", "next": "^11.1.2",
"palette-by-numbers": "^0.1.5", "palette-by-numbers": "^0.1.5",
"plur": "^4.0.0",
"react": "^17.0.2", "react": "^17.0.2",
"react-countdown": "^2.2.1", "react-countdown": "^2.2.1",
"react-device-detect": "^1.15.0", "react-device-detect": "^1.15.0",
@ -43,10 +43,10 @@
"react-table": "^7.7.0", "react-table": "^7.7.0",
"remark-gfm": "^1.0.0", "remark-gfm": "^1.0.0",
"string-format": "^2.0.0", "string-format": "^2.0.0",
"vest": "^3.2.3" "vest": "^3.2.3",
"zustand": "^3.5.10"
}, },
"devDependencies": { "devDependencies": {
"@hookstate/devtools": "^3.0.0",
"@types/dagre": "^0.7.44", "@types/dagre": "^0.7.44",
"@types/node": "^14.14.41", "@types/node": "^14.14.41",
"@types/react": "^17.0.3", "@types/react": "^17.0.3",

View file

@ -3,10 +3,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import type { AppProps } from 'next/app'; import type { AppProps } from 'next/app';
if (process.env.NODE_ENV === 'development') {
require('@hookstate/devtools');
}
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const App = (props: AppProps): JSX.Element => { const App = (props: AppProps): JSX.Element => {

View file

@ -1,22 +1,17 @@
import { State } from '@hookstate/core'; type AnyOption = {
export type TSelectOptionBase = {
label: string; label: string;
};
export type SingleOption = AnyOption & {
value: string; value: string;
group?: string; group?: string;
tags?: string[];
}; };
export type TSelectOption = TSelectOptionBase | null; export type OptionGroup = AnyOption & {
options: SingleOption[];
export type TSelectOptionMulti = TSelectOptionBase[] | null;
export type TSelectOptionState = State<TSelectOption>;
export type TSelectOptionGroup = {
label: string;
options: TSelectOption[];
}; };
export type SelectOption<T extends unknown = unknown> = (SingleOption | OptionGroup) & { data: T };
export type OnChangeArgs = { field: string; value: string | string[] }; 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[]; queryLocation: string[];
queryType: string; queryType: string;
queryTarget: string; queryTarget: string;
queryGroup: string;
} }
export interface TFormQuery extends Omit<FormData, 'queryLocation'> { 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 { FormData, TStringTableData, TQueryResponseString } from './data';
import type { TSelectOption } from './common';
import type { QueryContent, DirectiveSelect, Directive } from './config'; import type { QueryContent, DirectiveSelect, Directive } from './config';
export function isString(a: unknown): a is string { 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; 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. * 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 './common';
export * from './config'; export * from './config';
export * from './state';
export * from './theme'; 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: dependencies:
"@chakra-ui/utils" "1.8.0" "@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": "@emotion/babel-plugin@^11.3.0":
version "11.3.0" version "11.3.0"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.3.0.tgz#3a16850ba04d8d9651f07f3fb674b3436a4fb9d7" 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" resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.5.1.tgz#09f2228c9061c350819881dc11e4f65af90c5a1a"
integrity sha512-dNChuOJ7ker2ZALGhXO6KjeycwnBGcSslGsREgqTDSiBOgbEysupfGTJKzQsb0sXpNhrZLCGvQcT+qPmCdBEMw== 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": "@humanwhocodes/config-array@^0.5.0":
version "0.5.0" version "0.5.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" 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" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== 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: is-alphabetical@^1.0.0:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" 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" resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7"
integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== 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: pnp-webpack-plugin@1.6.4:
version "1.6.4" version "1.6.4"
resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149" 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" unist-util-visit "^2.0.0"
xtend "^4.0.1" 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: react-query@^3.16.0:
version "3.16.0" version "3.16.0"
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.16.0.tgz#8de1556aabb3d200d0f8eeb74ce2b0b3dd0a0a51" resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.16.0.tgz#8de1556aabb3d200d0f8eeb74ce2b0b3dd0a0a51"
@ -5299,19 +5305,6 @@ readdirp@~3.5.0:
dependencies: dependencies:
picomatch "^2.2.1" 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: redux@^4.0.0, redux@^4.1.0:
version "4.1.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4" resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4"
@ -5841,11 +5834,6 @@ supports-color@^8.0.0:
dependencies: dependencies:
has-flag "^4.0.0" 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: table@^6.0.9:
version "6.7.1" version "6.7.1"
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2" 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" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== 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: zwitch@^1.0.0:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"