mirror of
https://github.com/thatmattlove/hyperglass.git
synced 2026-01-17 08:48:05 +00:00
UI improvements, hookstate → zustand migration, cleanup
This commit is contained in:
parent
7d5d64c0e2
commit
85566b81ab
48 changed files with 1133 additions and 1038 deletions
|
|
@ -12,8 +12,13 @@ import {
|
|||
useDisclosure,
|
||||
ModalCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { HiOutlineDownload as RefreshIcon } from '@meronex/icons/hi';
|
||||
import { IosColorPalette as ThemeIcon } from '@meronex/icons/ios';
|
||||
import { MdcCodeJson as ConfigIcon } from '@meronex/icons/mdc';
|
||||
import { useConfig, useColorValue, useBreakpointValue } from '~/context';
|
||||
import { CodeBlock } from '~/components';
|
||||
import { useHyperglassConfig } from '~/hooks';
|
||||
|
||||
import type { UseDisclosureReturn } from '@chakra-ui/react';
|
||||
|
||||
interface TViewer extends Pick<UseDisclosureReturn, 'isOpen' | 'onClose'> {
|
||||
|
|
@ -50,6 +55,7 @@ export const Debugger: React.FC = () => {
|
|||
useBreakpointValue({ base: 'SMALL', md: 'MEDIUM', lg: 'LARGE', xl: 'X-LARGE' }) ?? 'UNKNOWN';
|
||||
const tagSize = useBreakpointValue({ base: 'sm', lg: 'lg' }) ?? 'lg';
|
||||
const btnSize = useBreakpointValue({ base: 'xs', lg: 'sm' }) ?? 'sm';
|
||||
const { refetch } = useHyperglassConfig();
|
||||
return (
|
||||
<>
|
||||
<HStack
|
||||
|
|
@ -69,12 +75,20 @@ export const Debugger: React.FC = () => {
|
|||
<Tag size={tagSize} colorScheme="gray">
|
||||
{colorMode.toUpperCase()}
|
||||
</Tag>
|
||||
<Button size={btnSize} colorScheme="blue" onClick={onConfigOpen}>
|
||||
<Button size={btnSize} leftIcon={<ConfigIcon />} colorScheme="cyan" onClick={onConfigOpen}>
|
||||
View Config
|
||||
</Button>
|
||||
<Button size={btnSize} colorScheme="red" onClick={onThemeOpen}>
|
||||
<Button size={btnSize} leftIcon={<ThemeIcon />} colorScheme="blue" onClick={onThemeOpen}>
|
||||
View Theme
|
||||
</Button>
|
||||
<Button
|
||||
size={btnSize}
|
||||
colorScheme="purple"
|
||||
leftIcon={<RefreshIcon />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Reload Config
|
||||
</Button>
|
||||
<Tag size={tagSize} colorScheme="teal">
|
||||
{mediaSize}
|
||||
</Tag>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Flex, FormControl, FormLabel, FormErrorMessage } from '@chakra-ui/react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { If } from '~/components';
|
||||
|
|
@ -6,6 +6,7 @@ import { useColorValue } from '~/context';
|
|||
import { useBooleanValue } from '~/hooks';
|
||||
|
||||
import type { FieldError } from 'react-hook-form';
|
||||
import type { FormData } from '~/types';
|
||||
import type { TField } from './types';
|
||||
|
||||
export const FormField: React.FC<TField> = (props: TField) => {
|
||||
|
|
@ -14,19 +15,17 @@ export const FormField: React.FC<TField> = (props: TField) => {
|
|||
const errorColor = useColorValue('red.500', 'red.300');
|
||||
const opacity = useBooleanValue(hiddenLabels, 0, undefined);
|
||||
|
||||
const [error, setError] = useState<Nullable<FieldError>>(null);
|
||||
const { formState } = useFormContext<FormData>();
|
||||
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (name in errors) {
|
||||
console.dir(errors);
|
||||
setError(errors[name]);
|
||||
console.warn(`Error on field '${label}': ${error?.message}`);
|
||||
const error = useMemo<FieldError | null>(() => {
|
||||
if (name in formState.errors) {
|
||||
console.group(`Error on field '${label}'`);
|
||||
console.warn(formState.errors[name as keyof FormData]);
|
||||
console.groupEnd();
|
||||
return formState.errors[name as keyof FormData] as FieldError;
|
||||
}
|
||||
}, [error, errors, label, name, setError]);
|
||||
return null;
|
||||
}, [formState, label, name]);
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
export * from './row';
|
||||
export * from './field';
|
||||
export * from './queryType';
|
||||
export * from './queryGroup';
|
||||
export * from './queryTarget';
|
||||
export * from './queryLocation';
|
||||
export * from './resolvedTarget';
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Wrap, VStack, Flex, Box, Avatar, chakra } from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { Select } from '~/components';
|
||||
import { useConfig } from '~/context';
|
||||
import { useLGState, useLGMethods } from '~/hooks';
|
||||
import { useConfig, useColorValue } from '~/context';
|
||||
import { useOpposingColor, useFormState } from '~/hooks';
|
||||
|
||||
import type { Network, TSelectOption } from '~/types';
|
||||
import type { TQuerySelectField } from './types';
|
||||
import type { Network, SingleOption, OptionGroup, FormData } from '~/types';
|
||||
import type { TQuerySelectField, LocationCardProps } from './types';
|
||||
|
||||
function buildOptions(networks: Network[]) {
|
||||
function buildOptions(networks: Network[]): OptionGroup[] {
|
||||
return networks
|
||||
.map(net => {
|
||||
const label = net.displayName;
|
||||
|
|
@ -23,42 +25,193 @@ function buildOptions(networks: Network[]) {
|
|||
.sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
|
||||
}
|
||||
|
||||
export const QueryLocation: React.FC<TQuerySelectField> = (props: TQuerySelectField) => {
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
const LocationCard = (props: LocationCardProps): JSX.Element => {
|
||||
const { option, onChange, defaultChecked, hasError } = props;
|
||||
const { label } = option;
|
||||
const [isChecked, setChecked] = useState(defaultChecked);
|
||||
|
||||
function handleChange(value: SingleOption) {
|
||||
if (isChecked) {
|
||||
setChecked(false);
|
||||
onChange('remove', value);
|
||||
} else {
|
||||
setChecked(true);
|
||||
onChange('add', value);
|
||||
}
|
||||
}
|
||||
|
||||
const bg = useColorValue('whiteAlpha.300', 'blackSolid.700');
|
||||
const imageBorder = useColorValue('gray.600', 'whiteAlpha.800');
|
||||
const fg = useOpposingColor(bg);
|
||||
const checkedBorder = useColorValue('blue.400', 'blue.300');
|
||||
const errorBorder = useColorValue('red.500', 'red.300');
|
||||
|
||||
const borderColor = useMemo(
|
||||
() =>
|
||||
hasError && isChecked
|
||||
? // Highlight red when there are no overlapping query types for the locations selected.
|
||||
errorBorder
|
||||
: isChecked && !hasError
|
||||
? // Highlight blue when any location is selected and there is no error.
|
||||
checkedBorder
|
||||
: // Otherwise, no border.
|
||||
'transparent',
|
||||
|
||||
[hasError, isChecked, checkedBorder, errorBorder],
|
||||
);
|
||||
return (
|
||||
<MotionBox
|
||||
py={4}
|
||||
px={6}
|
||||
bg={bg}
|
||||
w="100%"
|
||||
maxW="sm"
|
||||
mx="auto"
|
||||
shadow="lg"
|
||||
key={label}
|
||||
rounded="lg"
|
||||
cursor="pointer"
|
||||
borderWidth="1px"
|
||||
borderStyle="solid"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
borderColor={borderColor}
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
handleChange(option);
|
||||
}}
|
||||
>
|
||||
<Flex justifyContent="space-between" alignItems="center">
|
||||
<chakra.h2
|
||||
color={fg}
|
||||
fontWeight="bold"
|
||||
mt={{ base: 2, md: 0 }}
|
||||
fontSize={{ base: 'lg', md: 'xl' }}
|
||||
>
|
||||
{label}
|
||||
</chakra.h2>
|
||||
<Avatar
|
||||
color={fg}
|
||||
fit="cover"
|
||||
alt={label}
|
||||
name={label}
|
||||
boxSize={12}
|
||||
rounded="full"
|
||||
borderWidth={1}
|
||||
bg="whiteAlpha.300"
|
||||
borderStyle="solid"
|
||||
borderColor={imageBorder}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<chakra.p mt={2} color={fg} opacity={0.6} fontSize="sm">
|
||||
To do: add details field (and location image field)
|
||||
</chakra.p>
|
||||
</MotionBox>
|
||||
);
|
||||
};
|
||||
|
||||
export const QueryLocation = (props: TQuerySelectField): JSX.Element => {
|
||||
const { onChange, label } = props;
|
||||
|
||||
const { networks } = useConfig();
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
const { selections } = useLGState();
|
||||
const { exportState } = useLGMethods();
|
||||
|
||||
} = useFormContext<FormData>();
|
||||
const selections = useFormState(s => s.selections);
|
||||
const setSelection = useFormState(s => s.setSelection);
|
||||
const { form, filtered } = useFormState(({ form, filtered }) => ({ form, filtered }));
|
||||
const options = useMemo(() => buildOptions(networks), [networks]);
|
||||
const element = useMemo(() => {
|
||||
const groups = options.length;
|
||||
const maxOptionsPerGroup = Math.max(...options.map(opt => opt.options.length));
|
||||
const showCards = groups < 5 && maxOptionsPerGroup < 6;
|
||||
return showCards ? 'cards' : 'select';
|
||||
}, [options]);
|
||||
|
||||
function handleChange(e: TSelectOption | TSelectOption[]): void {
|
||||
if (e === null) {
|
||||
e = [];
|
||||
} else if (typeof e === 'string') {
|
||||
e = [e];
|
||||
}
|
||||
if (Array.isArray(e)) {
|
||||
const value = e.map(sel => sel!.value);
|
||||
onChange({ field: 'queryLocation', value });
|
||||
selections.queryLocation.set(e);
|
||||
const noOverlap = useMemo(
|
||||
() => form.queryLocation.length > 1 && filtered.types.length === 0,
|
||||
[form, filtered],
|
||||
);
|
||||
|
||||
/**
|
||||
* Update form and state when a card selections change.
|
||||
*
|
||||
* @param action Add or remove the option.
|
||||
* @param option Full option object.
|
||||
*/
|
||||
function handleCardChange(action: 'add' | 'remove', option: SingleOption) {
|
||||
const exists = selections.queryLocation.map(q => q.value).includes(option.value);
|
||||
if (action === 'add' && !exists) {
|
||||
const toAdd = [...form.queryLocation, option.value];
|
||||
const newSelections = [...selections.queryLocation, option];
|
||||
setSelection('queryLocation', newSelections);
|
||||
onChange({ field: 'queryLocation', value: toAdd });
|
||||
} else if (action === 'remove' && exists) {
|
||||
const index = selections.queryLocation.findIndex(v => v.value === option.value);
|
||||
const toRemove = [...form.queryLocation.filter(v => v !== option.value)];
|
||||
setSelection(
|
||||
'queryLocation',
|
||||
selections.queryLocation.filter((_, i) => i !== index),
|
||||
);
|
||||
onChange({ field: 'queryLocation', value: toRemove });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
isMulti
|
||||
size="lg"
|
||||
options={options}
|
||||
aria-label={label}
|
||||
name="queryLocation"
|
||||
onChange={handleChange}
|
||||
closeMenuOnSelect={false}
|
||||
value={exportState(selections.queryLocation.value)}
|
||||
isError={typeof errors.queryLocation !== 'undefined'}
|
||||
/>
|
||||
);
|
||||
/**
|
||||
* Update form and state when select element values change.
|
||||
*
|
||||
* @param options Final value. React-select determines if an option is being added or removed and
|
||||
* only sends back the final value.
|
||||
*/
|
||||
function handleSelectChange(options: SingleOption[] | SingleOption): void {
|
||||
if (Array.isArray(options)) {
|
||||
onChange({ field: 'queryLocation', value: options.map(o => o.value) });
|
||||
setSelection('queryLocation', options);
|
||||
} else {
|
||||
onChange({ field: 'queryLocation', value: options.value });
|
||||
setSelection('queryLocation', [options]);
|
||||
}
|
||||
}
|
||||
|
||||
if (element === 'cards') {
|
||||
return (
|
||||
<Wrap align="flex-start" justify={{ base: 'center', lg: 'space-between' }} shouldWrapChildren>
|
||||
{options.map(group => (
|
||||
<VStack key={group.label} align="center">
|
||||
<chakra.h3 fontSize={{ base: 'sm', md: 'md' }} alignSelf="flex-start" opacity={0.5}>
|
||||
{group.label}
|
||||
</chakra.h3>
|
||||
{group.options.map(opt => {
|
||||
return (
|
||||
<LocationCard
|
||||
key={opt.label}
|
||||
option={opt}
|
||||
onChange={handleCardChange}
|
||||
hasError={noOverlap}
|
||||
defaultChecked={form.queryLocation.includes(opt.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
} else if (element === 'select') {
|
||||
return (
|
||||
<Select
|
||||
isMulti
|
||||
size="lg"
|
||||
options={options}
|
||||
aria-label={label}
|
||||
name="queryLocation"
|
||||
onChange={handleSelectChange}
|
||||
closeMenuOnSelect={false}
|
||||
value={selections.queryLocation}
|
||||
isError={typeof errors.queryLocation !== 'undefined'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Flex>No Locations</Flex>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import { Input, Text } from '@chakra-ui/react';
|
|||
import { components } from 'react-select';
|
||||
import { If, Select } from '~/components';
|
||||
import { useColorValue } from '~/context';
|
||||
import { useLGState, useDirective } from '~/hooks';
|
||||
import { useDirective, useFormState } from '~/hooks';
|
||||
import { isSelectDirective } from '~/types';
|
||||
|
||||
import type { OptionProps } from 'react-select';
|
||||
import type { TSelectOption, Directive } from '~/types';
|
||||
import type { Directive, SingleOption } from '~/types';
|
||||
import type { TQueryTarget } from './types';
|
||||
|
||||
function buildOptions(directive: Nullable<Directive>): TSelectOption[] {
|
||||
function buildOptions(directive: Nullable<Directive>): SingleOption[] {
|
||||
if (directive !== null && isSelectDirective(directive)) {
|
||||
return directive.options.map(o => ({
|
||||
value: o.value,
|
||||
|
|
@ -41,27 +40,28 @@ export const QueryTarget: React.FC<TQueryTarget> = (props: TQueryTarget) => {
|
|||
const color = useColorValue('gray.400', 'whiteAlpha.800');
|
||||
const border = useColorValue('gray.100', 'whiteAlpha.50');
|
||||
const placeholderColor = useColorValue('gray.600', 'whiteAlpha.700');
|
||||
|
||||
const { queryTarget, displayTarget } = useLGState();
|
||||
const displayTarget = useFormState(s => s.target.display);
|
||||
const setTarget = useFormState(s => s.setTarget);
|
||||
const form = useFormState(s => s.form);
|
||||
const directive = useDirective();
|
||||
|
||||
const options = useMemo(() => buildOptions(directive), [directive]);
|
||||
|
||||
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>): void {
|
||||
displayTarget.set(e.target.value);
|
||||
setTarget({ display: e.target.value });
|
||||
onChange({ field: name, value: e.target.value });
|
||||
}
|
||||
|
||||
function handleSelectChange(e: TSelectOption | TSelectOption[]): void {
|
||||
function handleSelectChange(e: SingleOption | SingleOption[]): void {
|
||||
if (!Array.isArray(e) && e !== null) {
|
||||
onChange({ field: name, value: e.value });
|
||||
displayTarget.set(e.value);
|
||||
setTarget({ display: e.value });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input {...register('queryTarget')} hidden readOnly value={queryTarget.value} />
|
||||
<input {...register('queryTarget')} hidden readOnly value={form.queryTarget} />
|
||||
<If c={directive !== null && isSelectDirective(directive)}>
|
||||
<Select
|
||||
size="lg"
|
||||
|
|
@ -81,7 +81,7 @@ export const QueryTarget: React.FC<TQueryTarget> = (props: TQueryTarget) => {
|
|||
borderColor={border}
|
||||
aria-label={placeholder}
|
||||
placeholder={placeholder}
|
||||
value={displayTarget.value}
|
||||
value={displayTarget}
|
||||
name="queryTargetDisplay"
|
||||
onChange={handleInputChange}
|
||||
_placeholder={{ color: placeholderColor }}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,169 @@
|
|||
import { useMemo } from 'react';
|
||||
import create from 'zustand';
|
||||
import { Box, Button, HStack, useRadio, useRadioGroup } from '@chakra-ui/react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { components } from 'react-select';
|
||||
import { Select } from '~/components';
|
||||
import { useLGState, useLGMethods } from '~/hooks';
|
||||
import { useFormState } from '~/hooks';
|
||||
|
||||
import type { TSelectOption } from '~/types';
|
||||
import type { UseRadioProps } from '@chakra-ui/react';
|
||||
import type { MenuListComponentProps } from 'react-select';
|
||||
import type { SingleOption, OptionGroup, SelectOption } from '~/types';
|
||||
import type { TOptions } from '~/components/select';
|
||||
import type { TQuerySelectField } from './types';
|
||||
|
||||
export const QueryType: React.FC<TQuerySelectField> = (props: TQuerySelectField) => {
|
||||
function sorter<T extends SingleOption | OptionGroup>(a: T, b: T): number {
|
||||
return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
|
||||
}
|
||||
|
||||
type UserFilter = {
|
||||
selected: string;
|
||||
setSelected(n: string): void;
|
||||
filter(candidate: SelectOption<{ group: string | null }>, input: string): boolean;
|
||||
};
|
||||
|
||||
const useFilter = create<UserFilter>((set, get) => ({
|
||||
selected: '',
|
||||
setSelected(newValue: string) {
|
||||
set(() => ({ selected: newValue }));
|
||||
},
|
||||
filter(candidate, input): boolean {
|
||||
const {
|
||||
label,
|
||||
data: { group },
|
||||
} = candidate;
|
||||
if (input && (label || group)) {
|
||||
const search = input.toLowerCase();
|
||||
if (group) {
|
||||
return label.toLowerCase().indexOf(search) > -1 || group.toLowerCase().indexOf(search) > -1;
|
||||
}
|
||||
return label.toLowerCase().indexOf(search) > -1;
|
||||
}
|
||||
const { selected } = get();
|
||||
if (selected !== '' && selected === group) {
|
||||
return true;
|
||||
}
|
||||
if (selected === '') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
function useOptions() {
|
||||
const filtered = useFormState(s => s.filtered);
|
||||
return useMemo((): TOptions => {
|
||||
const groupNames = new Set(
|
||||
filtered.types
|
||||
.filter(t => t.groups.length > 0)
|
||||
.map(t => t.groups)
|
||||
.flat(),
|
||||
);
|
||||
const optGroups: OptionGroup[] = Array.from(groupNames).map(group => ({
|
||||
label: group,
|
||||
options: filtered.types
|
||||
.filter(t => t.groups.includes(group))
|
||||
.map(t => ({ label: t.name, value: t.id, group }))
|
||||
.sort(sorter),
|
||||
}));
|
||||
|
||||
const noGroups: OptionGroup = {
|
||||
label: '',
|
||||
options: filtered.types
|
||||
.filter(t => t.groups.length === 0)
|
||||
.map(t => ({ label: t.name, value: t.id, group: '' }))
|
||||
.sort(sorter),
|
||||
};
|
||||
|
||||
return [noGroups, ...optGroups].sort(sorter);
|
||||
}, [filtered.types]);
|
||||
}
|
||||
|
||||
const GroupFilter = (props: React.PropsWithChildren<UseRadioProps>): JSX.Element => {
|
||||
const { children, ...rest } = props;
|
||||
const {
|
||||
getInputProps,
|
||||
getCheckboxProps,
|
||||
getLabelProps,
|
||||
htmlProps,
|
||||
state: { isChecked },
|
||||
} = useRadio(rest);
|
||||
const label = getLabelProps();
|
||||
const input = getInputProps();
|
||||
const checkbox = getCheckboxProps();
|
||||
|
||||
return (
|
||||
<Box as="label" {...label}>
|
||||
<input {...input} />
|
||||
<Button
|
||||
{...checkbox}
|
||||
{...htmlProps}
|
||||
variant={isChecked ? 'solid' : 'outline'}
|
||||
colorScheme="gray"
|
||||
size="sm"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuList = (props: MenuListComponentProps<TOptions, false>) => {
|
||||
const { children, ...rest } = props;
|
||||
const filtered = useFormState(s => s.filtered);
|
||||
const selected = useFilter(state => state.selected);
|
||||
const setSelected = useFilter(state => state.setSelected);
|
||||
|
||||
const { getRadioProps, getRootProps } = useRadioGroup({
|
||||
name: 'queryGroup',
|
||||
value: selected,
|
||||
});
|
||||
|
||||
function handleClick(value: string): void {
|
||||
setSelected(value);
|
||||
}
|
||||
return (
|
||||
<components.MenuList {...rest}>
|
||||
<HStack pt={4} px={2} zIndex={2} {...getRootProps()}>
|
||||
<GroupFilter {...getRadioProps({ value: '', onClick: () => handleClick('') })}>
|
||||
None
|
||||
</GroupFilter>
|
||||
{filtered.groups.map(value => {
|
||||
return (
|
||||
<GroupFilter
|
||||
key={value}
|
||||
{...getRadioProps({ value, onClick: () => handleClick(value) })}
|
||||
>
|
||||
{value}
|
||||
</GroupFilter>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
{children}
|
||||
</components.MenuList>
|
||||
);
|
||||
};
|
||||
|
||||
export const QueryType = (props: TQuerySelectField): JSX.Element => {
|
||||
const { onChange, label } = props;
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
const { selections, availableTypes, queryType } = useLGState();
|
||||
const { exportState } = useLGMethods();
|
||||
const setSelection = useFormState(s => s.setSelection);
|
||||
const selections = useFormState(s => s.selections);
|
||||
const setFormValue = useFormState(s => s.setFormValue);
|
||||
const options = useOptions();
|
||||
const { filter } = useFilter(); // Intentionally re-render on any changes
|
||||
|
||||
const options = useMemo(
|
||||
() => availableTypes.map(t => ({ label: t.name.value, value: t.id.value })),
|
||||
[availableTypes],
|
||||
);
|
||||
|
||||
function handleChange(e: TSelectOption | TSelectOption[]): void {
|
||||
function handleChange(e: SingleOption | SingleOption[]): void {
|
||||
let value = '';
|
||||
if (!Array.isArray(e) && e !== null) {
|
||||
selections.queryType.set(e);
|
||||
// setFormValue('queryType', e.value);
|
||||
setSelection('queryType', e);
|
||||
value = e.value;
|
||||
} else {
|
||||
selections.queryType.set(null);
|
||||
queryType.set('');
|
||||
setFormValue('queryType', '');
|
||||
setSelection('queryType', null);
|
||||
}
|
||||
onChange({ field: 'queryType', value });
|
||||
}
|
||||
|
|
@ -37,9 +174,11 @@ export const QueryType: React.FC<TQuerySelectField> = (props: TQuerySelectField)
|
|||
name="queryType"
|
||||
options={options}
|
||||
aria-label={label}
|
||||
filterOption={filter}
|
||||
onChange={handleChange}
|
||||
value={exportState(selections.queryType.value)}
|
||||
components={{ MenuList }}
|
||||
isError={'queryType' in errors}
|
||||
value={selections.queryType}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Button, chakra, Stack, Text, VStack } from '@chakra-ui/react';
|
||||
import { useConfig, useColorValue } from '~/context';
|
||||
import { useStrf, useLGState, useDNSQuery } from '~/hooks';
|
||||
import { useStrf, useDNSQuery, useFormState } from '~/hooks';
|
||||
|
||||
import type { DnsOverHttps } from '~/types';
|
||||
import type { ResolvedTargetProps } from './types';
|
||||
|
||||
const RightArrow = chakra(
|
||||
dynamic<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleRight)),
|
||||
|
|
@ -12,9 +15,6 @@ const LeftArrow = chakra(
|
|||
dynamic<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaArrowCircleLeft)),
|
||||
);
|
||||
|
||||
import type { DnsOverHttps } from '~/types';
|
||||
import type { TResolvedTarget } from './types';
|
||||
|
||||
function findAnswer(data: DnsOverHttps.Response | undefined): string {
|
||||
let answer = '';
|
||||
if (typeof data !== 'undefined') {
|
||||
|
|
@ -24,18 +24,18 @@ function findAnswer(data: DnsOverHttps.Response | undefined): string {
|
|||
return answer;
|
||||
}
|
||||
|
||||
export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget) => {
|
||||
const { setTarget, errorClose } = props;
|
||||
export const ResolvedTarget = (props: ResolvedTargetProps): JSX.Element => {
|
||||
const { errorClose } = props;
|
||||
const strF = useStrf();
|
||||
const { web } = useConfig();
|
||||
const { displayTarget, isSubmitting, families, queryTarget } = useLGState();
|
||||
|
||||
const setStatus = useFormState(s => s.setStatus);
|
||||
const displayTarget = useFormState(s => s.target.display);
|
||||
const setFormValue = useFormState(s => s.setFormValue);
|
||||
|
||||
const color = useColorValue('secondary.500', 'secondary.300');
|
||||
const errorColor = useColorValue('red.500', 'red.300');
|
||||
|
||||
const query4 = Array.from(families.value).includes(4);
|
||||
const query6 = Array.from(families.value).includes(6);
|
||||
|
||||
const tooltip4 = strF(web.text.fqdnTooltip, { protocol: 'IPv4' });
|
||||
const tooltip6 = strF(web.text.fqdnTooltip, { protocol: 'IPv6' });
|
||||
|
||||
|
|
@ -47,14 +47,14 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
|
|||
isLoading: isLoading4,
|
||||
isError: isError4,
|
||||
error: error4,
|
||||
} = useDNSQuery(displayTarget.value, 4);
|
||||
} = useDNSQuery(displayTarget, 4);
|
||||
|
||||
const {
|
||||
data: data6,
|
||||
isLoading: isLoading6,
|
||||
isError: isError6,
|
||||
error: error6,
|
||||
} = useDNSQuery(displayTarget.value, 6);
|
||||
} = useDNSQuery(displayTarget, 6);
|
||||
|
||||
isError4 && console.error(error4);
|
||||
isError6 && console.error(error6);
|
||||
|
|
@ -62,39 +62,38 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
|
|||
const answer4 = useMemo(() => findAnswer(data4), [data4]);
|
||||
const answer6 = useMemo(() => findAnswer(data6), [data6]);
|
||||
|
||||
const handleOverride = useCallback(
|
||||
(value: string): void => setTarget({ field: 'queryTarget', value }),
|
||||
[setTarget],
|
||||
);
|
||||
|
||||
function selectTarget(value: string): void {
|
||||
queryTarget.set(value);
|
||||
isSubmitting.set(true);
|
||||
setFormValue('queryTarget', value);
|
||||
setStatus('results');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (query6 && data6?.Answer) {
|
||||
handleOverride(findAnswer(data6));
|
||||
} else if (query4 && data4?.Answer && !query6 && !data6?.Answer) {
|
||||
handleOverride(findAnswer(data4));
|
||||
} else if (query4 && data4?.Answer) {
|
||||
handleOverride(findAnswer(data4));
|
||||
}
|
||||
}, [data4, data6, handleOverride, query4, query6]);
|
||||
const hasAnswer = useMemo(
|
||||
() => (!isError4 || !isError6) && (answer4 !== '' || answer6 !== ''),
|
||||
[answer4, answer6, isError4, isError6],
|
||||
);
|
||||
const showA = useMemo(
|
||||
() => !isLoading4 && !isError4 && answer4 !== '',
|
||||
[isLoading4, isError4, answer4],
|
||||
);
|
||||
|
||||
const showAAAA = useMemo(
|
||||
() => !isLoading6 && !isError6 && answer6 !== '',
|
||||
[isLoading6, isError6, answer6],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack w="100%" spacing={4} justify="center">
|
||||
{(answer4 || answer6) && (
|
||||
{hasAnswer && (
|
||||
<Text fontSize="sm" textAlign="center">
|
||||
{messageStart}
|
||||
<Text as="span" fontSize="sm" fontWeight="bold" color={color}>
|
||||
{`${displayTarget.value}`.toLowerCase()}
|
||||
{`${displayTarget}`.toLowerCase()}
|
||||
</Text>
|
||||
{messageEnd}
|
||||
</Text>
|
||||
)}
|
||||
<Stack spacing={2}>
|
||||
{!isLoading4 && !isError4 && query4 && answer4 && (
|
||||
{showA && (
|
||||
<Button
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
|
|
@ -108,7 +107,7 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
|
|||
{answer4}
|
||||
</Button>
|
||||
)}
|
||||
{!isLoading6 && !isError6 && query6 && answer6 && (
|
||||
{showAAAA && (
|
||||
<Button
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
|
|
@ -122,18 +121,19 @@ export const ResolvedTarget: React.FC<TResolvedTarget> = (props: TResolvedTarget
|
|||
{answer6}
|
||||
</Button>
|
||||
)}
|
||||
{!answer4 && !answer6 && (
|
||||
{!hasAnswer && (
|
||||
<>
|
||||
<Text fontSize="sm" textAlign="center" color={errorColor}>
|
||||
{errorStart}
|
||||
<Text as="span" fontSize="sm" fontWeight="bold">
|
||||
{`${displayTarget.value}`.toLowerCase()}
|
||||
{`${displayTarget}`.toLowerCase()}
|
||||
</Text>
|
||||
{errorEnd}
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={errorClose}
|
||||
leftIcon={<LeftArrow />}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FormControlProps } from '@chakra-ui/react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import type { OnChangeArgs, FormData } from '~/types';
|
||||
import type { OnChangeArgs, FormData, SingleOption } from '~/types';
|
||||
|
||||
export interface TField extends FormControlProps {
|
||||
name: string;
|
||||
|
|
@ -17,10 +17,6 @@ export interface TQuerySelectField {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export interface TQueryGroup extends TQuerySelectField {
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface TQueryTarget {
|
||||
name: string;
|
||||
placeholder: string;
|
||||
|
|
@ -28,7 +24,13 @@ export interface TQueryTarget {
|
|||
onChange(e: OnChangeArgs): void;
|
||||
}
|
||||
|
||||
export interface TResolvedTarget {
|
||||
setTarget(e: OnChangeArgs): void;
|
||||
export interface ResolvedTargetProps {
|
||||
errorClose(): void;
|
||||
}
|
||||
|
||||
export interface LocationCardProps {
|
||||
option: SingleOption;
|
||||
defaultChecked: boolean;
|
||||
onChange(a: 'add' | 'remove', v: SingleOption): void;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,35 +17,22 @@ import type { TGreeting } from './types';
|
|||
|
||||
export const Greeting: React.FC<TGreeting> = (props: TGreeting) => {
|
||||
const { web, content } = useConfig();
|
||||
const { ack: greetingAck, isOpen, close } = useGreeting();
|
||||
const { isAck, isOpen, open, ack } = useGreeting();
|
||||
|
||||
const bg = useColorValue('white', 'gray.800');
|
||||
const color = useOpposingColor(bg);
|
||||
|
||||
function handleClose(ack: boolean = false): void {
|
||||
if (web.greeting.required && !greetingAck.value && !ack) {
|
||||
greetingAck.set(false);
|
||||
} else if (web.greeting.required && !greetingAck.value && ack) {
|
||||
greetingAck.set(true);
|
||||
close();
|
||||
} else if (web.greeting.required && greetingAck.value) {
|
||||
close();
|
||||
} else if (!web.greeting.required) {
|
||||
greetingAck.set(true);
|
||||
close();
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!greetingAck.value && web.greeting.enable) {
|
||||
isOpen.set(true);
|
||||
if (!isAck && web.greeting.enable) {
|
||||
open();
|
||||
}
|
||||
}, [greetingAck.value, isOpen, web.greeting.enable]);
|
||||
}, [isAck, open, web.greeting.enable]);
|
||||
return (
|
||||
<Modal
|
||||
size="lg"
|
||||
isCentered
|
||||
onClose={handleClose}
|
||||
isOpen={isOpen.value}
|
||||
onClose={() => ack(false)}
|
||||
isOpen={isOpen}
|
||||
motionPreset="slideInBottom"
|
||||
closeOnEsc={web.greeting.required}
|
||||
closeOnOverlayClick={web.greeting.required}
|
||||
|
|
@ -67,7 +54,7 @@ export const Greeting: React.FC<TGreeting> = (props: TGreeting) => {
|
|||
<Markdown content={content.greeting} />
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="primary" onClick={() => handleClose(true)}>
|
||||
<Button colorScheme="primary" onClick={() => ack(true)}>
|
||||
{web.greeting.button}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useRef } from 'react';
|
|||
import { Flex, ScaleFade } from '@chakra-ui/react';
|
||||
import { AnimatedDiv } from '~/components';
|
||||
import { useBreakpointValue } from '~/context';
|
||||
import { useBooleanValue, useLGState } from '~/hooks';
|
||||
import { useBooleanValue, useFormState } from '~/hooks';
|
||||
import { Title } from './title';
|
||||
|
||||
import type { THeader } from './types';
|
||||
|
|
@ -10,12 +10,12 @@ import type { THeader } from './types';
|
|||
export const Header: React.FC<THeader> = (props: THeader) => {
|
||||
const { resetForm, ...rest } = props;
|
||||
|
||||
const { isSubmitting } = useLGState();
|
||||
const status = useFormState(s => s.status);
|
||||
|
||||
const titleRef = useRef({} as HTMLDivElement);
|
||||
|
||||
const titleWidth = useBooleanValue(
|
||||
isSubmitting.value,
|
||||
status === 'results',
|
||||
{ base: '75%', lg: '50%' },
|
||||
{ base: '75%', lg: '75%' },
|
||||
);
|
||||
|
|
@ -33,7 +33,7 @@ export const Header: React.FC<THeader> = (props: THeader) => {
|
|||
maxW={titleWidth}
|
||||
// This is here for the logo
|
||||
justifyContent={justify}
|
||||
mx={{ base: isSubmitting.value ? 'auto' : 0, lg: 'auto' }}
|
||||
mx={{ base: status === 'results' ? 'auto' : 0, lg: 'auto' }}
|
||||
>
|
||||
<Title />
|
||||
</AnimatedDiv>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
|
|||
import { isSafari } from 'react-device-detect';
|
||||
import { If } from '~/components';
|
||||
import { useConfig, useMobile } from '~/context';
|
||||
import { useBooleanValue, useLGState, useLGMethods } from '~/hooks';
|
||||
import { useBooleanValue, useFormState } from '~/hooks';
|
||||
import { SubtitleOnly } from './subtitleOnly';
|
||||
import { TitleOnly } from './titleOnly';
|
||||
import { Logo } from './logo';
|
||||
|
|
@ -16,12 +16,12 @@ const AnimatedVStack = motion(VStack);
|
|||
* Title wrapper for mobile devices, breakpoints sm & md.
|
||||
*/
|
||||
const MWrapper: React.FC<TMWrapper> = (props: TMWrapper) => {
|
||||
const { isSubmitting } = useLGState();
|
||||
const status = useFormState(s => s.status);
|
||||
return (
|
||||
<AnimatedVStack
|
||||
layout
|
||||
spacing={1}
|
||||
alignItems={isSubmitting.value ? 'center' : 'flex-start'}
|
||||
alignItems={status === 'results' ? 'center' : 'flex-start'}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -31,15 +31,15 @@ const MWrapper: React.FC<TMWrapper> = (props: TMWrapper) => {
|
|||
* Title wrapper for desktop devices, breakpoints lg & xl.
|
||||
*/
|
||||
const DWrapper: React.FC<TDWrapper> = (props: TDWrapper) => {
|
||||
const { isSubmitting } = useLGState();
|
||||
const status = useFormState(s => s.status);
|
||||
return (
|
||||
<AnimatedVStack
|
||||
spacing={1}
|
||||
initial="main"
|
||||
alignItems="center"
|
||||
animate={isSubmitting.value ? 'submitting' : 'main'}
|
||||
animate={status}
|
||||
transition={{ damping: 15, type: 'spring', stiffness: 100 }}
|
||||
variants={{ submitting: { scale: 0.5 }, main: { scale: 1 } }}
|
||||
variants={{ results: { scale: 0.5 }, form: { scale: 1 } }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -108,15 +108,12 @@ export const Title: React.FC<TTitle> = (props: TTitle) => {
|
|||
const { web } = useConfig();
|
||||
const { titleMode } = web.text;
|
||||
|
||||
const { isSubmitting } = useLGState();
|
||||
const { resetForm } = useLGMethods();
|
||||
const { status, reset } = useFormState(({ status, reset }) => ({
|
||||
status,
|
||||
reset,
|
||||
}));
|
||||
|
||||
const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' });
|
||||
|
||||
function handleClick(): void {
|
||||
isSubmitting.set(false);
|
||||
resetForm();
|
||||
}
|
||||
const titleHeight = useBooleanValue(status === 'results', undefined, { md: '20vh' });
|
||||
|
||||
return (
|
||||
<Flex
|
||||
|
|
@ -132,7 +129,7 @@ export const Title: React.FC<TTitle> = (props: TTitle) => {
|
|||
div up to the parent's max-width. The fix is to hard-code a flex-basis width.
|
||||
*/
|
||||
flexBasis={{ base: '100%', lg: isSafari ? '33%' : '100%' }}
|
||||
mt={[null, isSubmitting.value ? null : 'auto']}
|
||||
mt={{ md: status === 'results' ? undefined : 'auto' }}
|
||||
{...rest}
|
||||
>
|
||||
<Button
|
||||
|
|
@ -140,7 +137,7 @@ export const Title: React.FC<TTitle> = (props: TTitle) => {
|
|||
variant="link"
|
||||
flexWrap="wrap"
|
||||
flexDir="column"
|
||||
onClick={handleClick}
|
||||
onClick={() => reset()}
|
||||
_focus={{ boxShadow: 'none' }}
|
||||
_hover={{ textDecoration: 'none' }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { Heading } from '@chakra-ui/react';
|
||||
import { useConfig } from '~/context';
|
||||
import { useBooleanValue, useLGState } from '~/hooks';
|
||||
import { useBooleanValue, useFormState } from '~/hooks';
|
||||
import { useTitleSize } from './useTitleSize';
|
||||
|
||||
export const TitleOnly: React.FC = () => {
|
||||
const { web } = useConfig();
|
||||
const { isSubmitting } = useLGState();
|
||||
|
||||
const margin = useBooleanValue(isSubmitting.value, 0, 2);
|
||||
const status = useFormState(s => s.status);
|
||||
const margin = useBooleanValue(status === 'results', 0, 2);
|
||||
const sizeSm = useTitleSize(web.text.title, '2xl', []);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Flex } from '@chakra-ui/react';
|
||||
import { isSafari } from 'react-device-detect';
|
||||
import { If, Debugger, Greeting, Footer, Header } from '~/components';
|
||||
import { useConfig } from '~/context';
|
||||
import { useLGState, useLGMethods, useGoogleAnalytics } from '~/hooks';
|
||||
import { useGoogleAnalytics, useFormState } from '~/hooks';
|
||||
import { ResetButton } from './resetButton';
|
||||
|
||||
import type { TFrame } from './types';
|
||||
|
|
@ -12,16 +12,18 @@ import type { TFrame } from './types';
|
|||
export const Frame = (props: TFrame): JSX.Element => {
|
||||
const router = useRouter();
|
||||
const { developerMode, googleAnalytics } = useConfig();
|
||||
const { isSubmitting } = useLGState();
|
||||
const { resetForm } = useLGMethods();
|
||||
const { setStatus, reset } = useFormState(
|
||||
useCallback(({ setStatus, reset }) => ({ setStatus, reset }), []),
|
||||
);
|
||||
|
||||
const { initialize, trackPage } = useGoogleAnalytics();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>({} as HTMLDivElement);
|
||||
|
||||
function handleReset(): void {
|
||||
containerRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
isSubmitting.set(false);
|
||||
resetForm();
|
||||
setStatus('form');
|
||||
reset();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -48,7 +50,7 @@ export const Frame = (props: TFrame): JSX.Element => {
|
|||
*/
|
||||
minHeight={isSafari ? '-webkit-fill-available' : '100vh'}
|
||||
>
|
||||
<Header resetForm={handleReset} />
|
||||
<Header resetForm={() => handleReset()} />
|
||||
<Flex
|
||||
px={4}
|
||||
py={0}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import { AnimatePresence } from 'framer-motion';
|
||||
import { LookingGlass, Results } from '~/components';
|
||||
import { useLGMethods } from '~/hooks';
|
||||
import { useView } from '~/hooks';
|
||||
import { Frame } from './frame';
|
||||
|
||||
export const Layout: React.FC = () => {
|
||||
const { formReady } = useLGMethods();
|
||||
const ready = formReady();
|
||||
export const Layout = (): JSX.Element => {
|
||||
const view = useView();
|
||||
return (
|
||||
<Frame>
|
||||
{ready ? (
|
||||
{view === 'results' ? (
|
||||
<Results />
|
||||
) : (
|
||||
<AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -3,20 +3,20 @@ import { Flex, Icon, IconButton } from '@chakra-ui/react';
|
|||
import { AnimatePresence } from 'framer-motion';
|
||||
import { AnimatedDiv } from '~/components';
|
||||
import { useColorValue } from '~/context';
|
||||
import { useLGState, useOpposingColor } from '~/hooks';
|
||||
import { useOpposingColor, useFormState } from '~/hooks';
|
||||
|
||||
import type { TResetButton } from './types';
|
||||
|
||||
const LeftArrow = dynamic<MeronexIcon>(() => import('@meronex/icons/fa').then(i => i.FaAngleLeft));
|
||||
|
||||
export const ResetButton: React.FC<TResetButton> = (props: TResetButton) => {
|
||||
export const ResetButton = (props: TResetButton): JSX.Element => {
|
||||
const { developerMode, resetForm, ...rest } = props;
|
||||
const { isSubmitting } = useLGState();
|
||||
const status = useFormState(s => s.status);
|
||||
const bg = useColorValue('primary.500', 'primary.300');
|
||||
const color = useOpposingColor(bg);
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isSubmitting.value && (
|
||||
{status === 'results' && (
|
||||
<AnimatedDiv
|
||||
bg={bg}
|
||||
left={0}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Flex, ScaleFade, SlideFade } from '@chakra-ui/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { intersectionWith } from 'lodash';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { vestResolver } from '@hookform/resolvers/vest';
|
||||
import vest, { test, enforce } from 'vest';
|
||||
import {
|
||||
If,
|
||||
FormRow,
|
||||
QueryGroup,
|
||||
FormField,
|
||||
HelpModal,
|
||||
QueryType,
|
||||
|
|
@ -18,8 +15,7 @@ import {
|
|||
QueryLocation,
|
||||
} from '~/components';
|
||||
import { useConfig } from '~/context';
|
||||
import { useStrf, useGreeting, useDevice, useLGState, useLGMethods } from '~/hooks';
|
||||
import { dedupObjectArray } from '~/util';
|
||||
import { useStrf, useGreeting, useDevice, useFormState } from '~/hooks';
|
||||
import { isString, isQueryField, Directive } from '~/types';
|
||||
|
||||
import type { FormData, OnChangeArgs } from '~/types';
|
||||
|
|
@ -36,39 +32,32 @@ const fqdnPattern = new RegExp(
|
|||
/^(?!:\/\/)([a-zA-Z0-9-]+\.)?[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z-]{2,6}?$/im,
|
||||
);
|
||||
|
||||
function useIsFqdn(target: string, _type: string) {
|
||||
return useCallback(
|
||||
(): boolean => ['bgp_route', 'ping', 'traceroute'].includes(_type) && fqdnPattern.test(target),
|
||||
[target, _type],
|
||||
);
|
||||
}
|
||||
|
||||
export const LookingGlass: React.FC = () => {
|
||||
export const LookingGlass = (): JSX.Element => {
|
||||
const { web, messages } = useConfig();
|
||||
|
||||
const { ack, greetingReady } = useGreeting();
|
||||
const greetingReady = useGreeting(s => s.greetingReady);
|
||||
|
||||
const getDevice = useDevice();
|
||||
const strF = useStrf();
|
||||
const setLoading = useFormState(s => s.setLoading);
|
||||
const setStatus = useFormState(s => s.setStatus);
|
||||
const locationChange = useFormState(s => s.locationChange);
|
||||
const setTarget = useFormState(s => s.setTarget);
|
||||
const setFormValue = useFormState(s => s.setFormValue);
|
||||
const { form, filtered } = useFormState(
|
||||
useCallback(({ form, filtered }) => ({ form, filtered }), []),
|
||||
isEqual,
|
||||
);
|
||||
|
||||
const getDirective = useFormState(useCallback(s => s.getDirective, []));
|
||||
const resolvedOpen = useFormState(useCallback(s => s.resolvedOpen, []));
|
||||
const resetForm = useFormState(useCallback(s => s.reset, []));
|
||||
|
||||
const noQueryType = strF(messages.noInput, { field: web.text.queryType });
|
||||
const noQueryLoc = strF(messages.noInput, { field: web.text.queryLocation });
|
||||
const noQueryTarget = strF(messages.noInput, { field: web.text.queryTarget });
|
||||
|
||||
const {
|
||||
availableGroups,
|
||||
queryType,
|
||||
directive,
|
||||
availableTypes,
|
||||
btnLoading,
|
||||
queryGroup,
|
||||
queryTarget,
|
||||
isSubmitting,
|
||||
queryLocation,
|
||||
displayTarget,
|
||||
selections,
|
||||
} = useLGState();
|
||||
|
||||
const queryTypes = useMemo(() => availableTypes.map(t => t.id.value), [availableTypes]);
|
||||
const queryTypes = useMemo(() => filtered.types.map(t => t.id), [filtered.types]);
|
||||
|
||||
const formSchema = vest.create((data: FormData = {} as FormData) => {
|
||||
test('queryLocation', noQueryLoc, () => {
|
||||
|
|
@ -80,9 +69,6 @@ export const LookingGlass: React.FC = () => {
|
|||
test('queryType', noQueryType, () => {
|
||||
enforce(data.queryType).inside(queryTypes);
|
||||
});
|
||||
test('queryGroup', 'Query Group is empty', () => {
|
||||
enforce(data.queryGroup).isString();
|
||||
});
|
||||
});
|
||||
|
||||
const formInstance = useForm<FormData>({
|
||||
|
|
@ -91,155 +77,65 @@ export const LookingGlass: React.FC = () => {
|
|||
queryTarget: '',
|
||||
queryLocation: [],
|
||||
queryType: '',
|
||||
queryGroup: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { handleSubmit, register, setValue, setError, clearErrors } = formInstance;
|
||||
|
||||
const { resolvedOpen, resetForm, getDirective } = useLGMethods();
|
||||
// const isFqdnQuery = useIsFqdn(form.queryTarget, form.queryType);
|
||||
const isFqdnQuery = useCallback(
|
||||
(target: string, fieldType: Directive['fieldType'] | null): boolean =>
|
||||
fieldType === 'text' && fqdnPattern.test(target),
|
||||
[],
|
||||
);
|
||||
|
||||
const isFqdnQuery = useIsFqdn(queryTarget.value, queryType.value);
|
||||
|
||||
const selectedDirective = useMemo(() => {
|
||||
if (queryType.value === '') {
|
||||
return null;
|
||||
}
|
||||
const directive = getDirective(queryType.value);
|
||||
if (directive !== null) {
|
||||
return directive;
|
||||
}
|
||||
return null;
|
||||
const directive = useMemo<Directive | null>(
|
||||
() => getDirective(),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryType.value, queryGroup.value, getDirective]);
|
||||
[form.queryType, form.queryLocation, getDirective],
|
||||
);
|
||||
|
||||
function submitHandler() {
|
||||
function submitHandler(): void {
|
||||
console.table({
|
||||
'Query Location': queryLocation.value,
|
||||
'Query Type': queryType.value,
|
||||
'Query Group': queryGroup.value,
|
||||
'Query Target': queryTarget.value,
|
||||
'Selected Directive': selectedDirective?.name ?? null,
|
||||
'Query Location': form.queryLocation.toString(),
|
||||
'Query Type': form.queryType,
|
||||
'Query Target': form.queryTarget,
|
||||
'Selected Directive': directive?.name ?? null,
|
||||
});
|
||||
/**
|
||||
* Before submitting a query, make sure the greeting is acknowledged if required. This should
|
||||
* be handled before loading the app, but people be sneaky.
|
||||
*/
|
||||
if (!greetingReady()) {
|
||||
// Before submitting a query, make sure the greeting is acknowledged if required. This should
|
||||
// be handled before loading the app, but people be sneaky.
|
||||
|
||||
if (!greetingReady) {
|
||||
resetForm();
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if queryTarget is an FQDN.
|
||||
const isFqdn = isFqdnQuery();
|
||||
const isFqdn = isFqdnQuery(form.queryTarget, directive?.fieldType ?? null);
|
||||
|
||||
if (greetingReady() && !isFqdn) {
|
||||
return isSubmitting.set(true);
|
||||
if (greetingReady && !isFqdn) {
|
||||
return setStatus('results');
|
||||
}
|
||||
|
||||
if (greetingReady() && isFqdn) {
|
||||
btnLoading.set(true);
|
||||
if (greetingReady && isFqdn) {
|
||||
setLoading(true);
|
||||
return resolvedOpen();
|
||||
} else {
|
||||
console.group('%cSomething went wrong', 'color:red;');
|
||||
console.table({
|
||||
'Greeting Required': web.greeting.required,
|
||||
'Greeting Ready': greetingReady(),
|
||||
'Greeting Acknowledged': ack.value,
|
||||
'Query Target': queryTarget.value,
|
||||
'Query Type': queryType.value,
|
||||
'Greeting Ready': greetingReady,
|
||||
'Query Target': form.queryTarget,
|
||||
'Query Type': form.queryType,
|
||||
'Is FQDN': isFqdn,
|
||||
});
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
|
||||
function handleLocChange(locations: string[]): void {
|
||||
clearErrors('queryLocation');
|
||||
const locationNames = [] as string[];
|
||||
const allGroups = [] as string[][];
|
||||
const allTypes = [] as Directive[][];
|
||||
const allDevices = [];
|
||||
|
||||
queryLocation.set(locations);
|
||||
|
||||
// Create an array of each device's VRFs.
|
||||
for (const loc of locations) {
|
||||
const device = getDevice(loc);
|
||||
locationNames.push(device.name);
|
||||
allDevices.push(device);
|
||||
const groups = new Set<string>();
|
||||
for (const directive of device.directives) {
|
||||
for (const group of directive.groups) {
|
||||
groups.add(group);
|
||||
}
|
||||
}
|
||||
allGroups.push(Array.from(groups));
|
||||
}
|
||||
|
||||
const intersecting = intersectionWith(...allGroups, isEqual);
|
||||
|
||||
if (!intersecting.includes(queryGroup.value)) {
|
||||
queryGroup.set('');
|
||||
queryType.set('');
|
||||
directive.set(null);
|
||||
selections.merge({ queryGroup: null, queryType: null });
|
||||
}
|
||||
|
||||
for (const group of intersecting) {
|
||||
for (const device of allDevices) {
|
||||
for (const directive of device.directives) {
|
||||
if (directive.groups.includes(group)) {
|
||||
// allTypes.add(directive.name);
|
||||
allTypes.push(device.directives);
|
||||
// allTypes.push(device.directives.map(d => d.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const intersectingTypes = intersectionWith(...allTypes, isEqual);
|
||||
|
||||
availableGroups.set(intersecting);
|
||||
availableTypes.set(intersectingTypes);
|
||||
|
||||
// If there is more than one location selected, but there are no intersecting VRFs, show an error.
|
||||
if (locations.length > 1 && intersecting.length === 0) {
|
||||
setError('queryLocation', {
|
||||
// message: `${locationNames.join(', ')} have no VRFs in common.`,
|
||||
message: `${locationNames.join(', ')} have no groups in common.`,
|
||||
});
|
||||
}
|
||||
// If there is only one intersecting VRF, set it as the form value so the user doesn't have to.
|
||||
else if (intersecting.length === 1) {
|
||||
queryGroup.set(intersecting[0]);
|
||||
}
|
||||
if (availableGroups.length > 1 && intersectingTypes.length === 0) {
|
||||
setError('queryLocation', {
|
||||
message: `${locationNames.join(', ')} have no query types in common.`,
|
||||
});
|
||||
} else if (intersectingTypes.length === 1) {
|
||||
queryType.set(intersectingTypes[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupChange(group: string): void {
|
||||
queryGroup.set(group);
|
||||
let availTypes = new Array<Directive>();
|
||||
for (const loc of queryLocation) {
|
||||
const device = getDevice(loc.value);
|
||||
for (const directive of device.directives) {
|
||||
if (directive.groups.includes(group)) {
|
||||
availTypes.push(directive);
|
||||
}
|
||||
}
|
||||
}
|
||||
availTypes = dedupObjectArray<Directive>(availTypes, 'id');
|
||||
availableTypes.set(availTypes);
|
||||
if (availableTypes.length === 1) {
|
||||
queryType.set(availableTypes[0].name.value);
|
||||
}
|
||||
}
|
||||
const handleLocChange = (locations: string[]) =>
|
||||
locationChange(locations, { setError, clearErrors, getDevice, text: web.text });
|
||||
|
||||
function handleChange(e: OnChangeArgs): void {
|
||||
// Signal the field & value to react-hook-form.
|
||||
|
|
@ -252,27 +148,23 @@ export const LookingGlass: React.FC = () => {
|
|||
if (e.field === 'queryLocation' && Array.isArray(e.value)) {
|
||||
handleLocChange(e.value);
|
||||
} else if (e.field === 'queryType' && isString(e.value)) {
|
||||
queryType.set(e.value);
|
||||
if (queryTarget.value !== '') {
|
||||
setValue('queryType', e.value);
|
||||
setFormValue('queryType', e.value);
|
||||
if (form.queryTarget !== '') {
|
||||
// Reset queryTarget as well, so that, for example, selecting BGP Community, and selecting
|
||||
// a community, then changing the queryType to BGP Route doesn't preserve the selected
|
||||
// community as the queryTarget.
|
||||
queryTarget.set('');
|
||||
displayTarget.set('');
|
||||
setFormValue('queryTarget', '');
|
||||
setTarget({ display: '' });
|
||||
}
|
||||
} else if (e.field === 'queryTarget' && isString(e.value)) {
|
||||
queryTarget.set(e.value);
|
||||
} else if (e.field === 'queryGroup' && isString(e.value)) {
|
||||
// queryGroup.set(e.value);
|
||||
handleGroupChange(e.value);
|
||||
setFormValue('queryTarget', e.value);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
register('queryLocation', { required: true });
|
||||
// register('queryTarget', { required: true });
|
||||
register('queryType', { required: true });
|
||||
register('queryGroup');
|
||||
}, [register]);
|
||||
|
||||
return (
|
||||
|
|
@ -295,25 +187,16 @@ export const LookingGlass: React.FC = () => {
|
|||
<FormField name="queryLocation" label={web.text.queryLocation}>
|
||||
<QueryLocation onChange={handleChange} label={web.text.queryLocation} />
|
||||
</FormField>
|
||||
<If c={availableGroups.length > 1}>
|
||||
<FormField label={web.text.queryGroup} name="queryGroup">
|
||||
<QueryGroup
|
||||
label={web.text.queryGroup}
|
||||
groups={availableGroups.value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</FormField>
|
||||
</If>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<SlideFade offsetX={-100} in={availableTypes.length > 1} unmountOnExit>
|
||||
<SlideFade offsetY={100} in={filtered.types.length > 0} unmountOnExit>
|
||||
<FormField
|
||||
name="queryType"
|
||||
label={web.text.queryType}
|
||||
labelAddOn={
|
||||
<HelpModal
|
||||
visible={selectedDirective?.info.value !== null}
|
||||
item={selectedDirective?.info.value ?? null}
|
||||
visible={directive?.info !== null}
|
||||
item={directive?.info ?? null}
|
||||
name="queryType"
|
||||
/>
|
||||
}
|
||||
|
|
@ -321,14 +204,14 @@ export const LookingGlass: React.FC = () => {
|
|||
<QueryType onChange={handleChange} label={web.text.queryType} />
|
||||
</FormField>
|
||||
</SlideFade>
|
||||
<SlideFade offsetX={100} in={selectedDirective !== null} unmountOnExit>
|
||||
{selectedDirective !== null && (
|
||||
<SlideFade offsetX={100} in={directive !== null} unmountOnExit>
|
||||
{directive !== null && (
|
||||
<FormField name="queryTarget" label={web.text.queryTarget}>
|
||||
<QueryTarget
|
||||
name="queryTarget"
|
||||
register={register}
|
||||
onChange={handleChange}
|
||||
placeholder={selectedDirective.description.value}
|
||||
placeholder={directive.description}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
|
@ -344,8 +227,8 @@ export const LookingGlass: React.FC = () => {
|
|||
flexDir="column"
|
||||
mr={{ base: 0, lg: 2 }}
|
||||
>
|
||||
<ScaleFade initialScale={0.5} in={queryTarget.value !== ''}>
|
||||
<SubmitButton handleChange={handleChange} />
|
||||
<ScaleFade initialScale={0.5} in={form.queryTarget !== ''}>
|
||||
<SubmitButton />
|
||||
</ScaleFade>
|
||||
</Flex>
|
||||
</FormRow>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
ModalCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { useColorValue, useBreakpointValue } from '~/context';
|
||||
import { useLGState, useLGMethods } from '~/hooks';
|
||||
import { useFormState } from '~/hooks';
|
||||
import { PathButton } from './button';
|
||||
import { Chart } from './chart';
|
||||
|
||||
|
|
@ -17,8 +17,8 @@ import type { TPath } from './types';
|
|||
|
||||
export const Path: React.FC<TPath> = (props: TPath) => {
|
||||
const { device } = props;
|
||||
const { displayTarget } = useLGState();
|
||||
const { getResponse } = useLGMethods();
|
||||
const displayTarget = useFormState(s => s.target.display);
|
||||
const getResponse = useFormState(s => s.response);
|
||||
const { isOpen, onClose, onOpen } = useDisclosure();
|
||||
const response = getResponse(device);
|
||||
const output = response?.output as StructuredResponse;
|
||||
|
|
@ -35,7 +35,7 @@ export const Path: React.FC<TPath> = (props: TPath) => {
|
|||
maxH={{ base: '80%', lg: '60%' }}
|
||||
maxW={{ base: '100%', lg: '80%' }}
|
||||
>
|
||||
<ModalHeader>{`Path to ${displayTarget.value}`}</ModalHeader>
|
||||
<ModalHeader>{`Path to ${displayTarget}`}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{response !== null ? <Chart data={output} /> : <Skeleton w="500px" h="300px" />}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ import { useEffect } from 'react';
|
|||
import { Accordion } from '@chakra-ui/react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { AnimatedDiv } from '~/components';
|
||||
import { useDevice, useLGState } from '~/hooks';
|
||||
import { useFormState } from '~/hooks';
|
||||
import { Result } from './individual';
|
||||
import { Tags } from './tags';
|
||||
|
||||
export const Results: React.FC = () => {
|
||||
const { queryLocation, queryTarget, queryType, queryGroup } = useLGState();
|
||||
|
||||
const getDevice = useDevice();
|
||||
const { queryLocation } = useFormState(s => s.form);
|
||||
|
||||
// Scroll to the top of the page when results load - primarily for mobile.
|
||||
useEffect(() => {
|
||||
|
|
@ -38,20 +36,9 @@ export const Results: React.FC = () => {
|
|||
>
|
||||
<Accordion allowMultiple allowToggle>
|
||||
<AnimatePresence>
|
||||
{queryLocation.value &&
|
||||
queryLocation.map((loc, i) => {
|
||||
const device = getDevice(loc.value);
|
||||
return (
|
||||
<Result
|
||||
index={i}
|
||||
device={device}
|
||||
key={device.id}
|
||||
queryLocation={loc.value}
|
||||
queryType={queryType.value}
|
||||
queryGroup={queryGroup.value}
|
||||
queryTarget={queryTarget.value}
|
||||
/>
|
||||
);
|
||||
{queryLocation.length > 0 &&
|
||||
queryLocation.map((location, index) => {
|
||||
return <Result index={index} key={location} queryLocation={location} />;
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,24 @@
|
|||
import { forwardRef, useEffect, useMemo } from 'react';
|
||||
import { forwardRef, memo, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
chakra,
|
||||
Icon,
|
||||
Alert,
|
||||
chakra,
|
||||
HStack,
|
||||
Tooltip,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
useAccordionContext,
|
||||
AccordionButton,
|
||||
useAccordionContext,
|
||||
} from '@chakra-ui/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { BsLightningFill } from '@meronex/icons/bs';
|
||||
import { startCase } from 'lodash';
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { BGPTable, Countdown, TextOutput, If, Path } from '~/components';
|
||||
import { useColorValue, useConfig, useMobile } from '~/context';
|
||||
import { useStrf, useLGQuery, useLGState, useTableToString } from '~/hooks';
|
||||
import { useStrf, useLGQuery, useTableToString, useFormState, useDevice } from '~/hooks';
|
||||
import { isStructuredOutput, isStringOutput } from '~/types';
|
||||
import { isStackError, isFetchError, isLGError, isLGOutputOrError } from './guards';
|
||||
import { RequeryButton } from './requeryButton';
|
||||
|
|
@ -25,7 +26,7 @@ import { CopyButton } from './copyButton';
|
|||
import { FormattedError } from './error';
|
||||
import { ResultHeader } from './header';
|
||||
|
||||
import type { TResult, TErrorLevels } from './types';
|
||||
import type { ResultProps, TErrorLevels } from './types';
|
||||
|
||||
const AnimatedAccordionItem = motion(AccordionItem);
|
||||
|
||||
|
|
@ -38,11 +39,15 @@ const AccordionHeaderWrapper = chakra('div', {
|
|||
},
|
||||
});
|
||||
|
||||
const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props: TResult, ref) => {
|
||||
const { index, device, queryType, queryTarget, queryLocation, queryGroup } = props;
|
||||
|
||||
const _Result: React.ForwardRefRenderFunction<HTMLDivElement, ResultProps> = (
|
||||
props: ResultProps,
|
||||
ref,
|
||||
) => {
|
||||
const { index, queryLocation } = props;
|
||||
const { web, cache, messages } = useConfig();
|
||||
const { index: indices, setIndex } = useAccordionContext();
|
||||
const getDevice = useDevice();
|
||||
const device = getDevice(queryLocation);
|
||||
|
||||
const isMobile = useMobile();
|
||||
const color = useColorValue('black', 'white');
|
||||
|
|
@ -50,20 +55,26 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
|
|||
const scrollbarHover = useColorValue('blackAlpha.400', 'whiteAlpha.400');
|
||||
const scrollbarBg = useColorValue('blackAlpha.50', 'whiteAlpha.50');
|
||||
|
||||
const { responses } = useLGState();
|
||||
|
||||
const { data, error, isError, isLoading, refetch, isFetchedAfterMount } = useLGQuery({
|
||||
queryLocation,
|
||||
queryTarget,
|
||||
queryType,
|
||||
queryGroup,
|
||||
});
|
||||
const addResponse = useFormState(s => s.addResponse);
|
||||
const form = useFormState(s => s.form);
|
||||
|
||||
const { data, error, isError, isLoading, refetch, isFetchedAfterMount } = useLGQuery(
|
||||
{
|
||||
queryLocation,
|
||||
queryTarget: form.queryTarget,
|
||||
queryType: form.queryType,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
addResponse(device.id, data);
|
||||
},
|
||||
onError(error) {
|
||||
console.error(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
const isCached = useMemo(() => data?.cached || !isFetchedAfterMount, [data, isFetchedAfterMount]);
|
||||
|
||||
if (typeof data !== 'undefined') {
|
||||
responses.merge({ [device.id]: data });
|
||||
}
|
||||
const strF = useStrf();
|
||||
const cacheLabel = strF(web.text.cacheIcon, { time: data?.timestamp });
|
||||
|
||||
|
|
@ -92,8 +103,6 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
|
|||
}
|
||||
}, [error, data, messages.general, messages.requestTimeout]);
|
||||
|
||||
isError && console.error(error);
|
||||
|
||||
const errorLevel = useMemo<TErrorLevels>(() => {
|
||||
const statusMap = {
|
||||
success: 'success',
|
||||
|
|
@ -113,15 +122,15 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
|
|||
|
||||
const tableComponent = useMemo<boolean>(() => {
|
||||
let result = false;
|
||||
if (typeof queryType.match(/^bgp_\w+$/) !== null && data?.format === 'application/json') {
|
||||
if (data?.format === 'application/json') {
|
||||
result = true;
|
||||
}
|
||||
return result;
|
||||
}, [queryType, data?.format]);
|
||||
}, [data?.format]);
|
||||
|
||||
let copyValue = data?.output as string;
|
||||
|
||||
const formatData = useTableToString(queryTarget, data, [data?.format]);
|
||||
const formatData = useTableToString(form.queryTarget, data, [data?.format]);
|
||||
|
||||
if (data?.format === 'application/json') {
|
||||
copyValue = formatData();
|
||||
|
|
@ -141,7 +150,6 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
|
|||
}
|
||||
}
|
||||
}, [data, index, indices, isLoading, isError, setIndex]);
|
||||
|
||||
return (
|
||||
<AnimatedAccordionItem
|
||||
ref={ref}
|
||||
|
|
@ -250,4 +258,4 @@ const _Result: React.ForwardRefRenderFunction<HTMLDivElement, TResult> = (props:
|
|||
);
|
||||
};
|
||||
|
||||
export const Result = forwardRef<HTMLDivElement, TResult>(_Result);
|
||||
export const Result = memo(forwardRef<HTMLDivElement, ResultProps>(_Result), isEqual);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Box, Stack, useToken } from '@chakra-ui/react';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Label } from '~/components';
|
||||
import { useConfig, useBreakpointValue } from '~/context';
|
||||
import { useLGState, useLGMethods } from '~/hooks';
|
||||
import { useFormState } from '~/hooks';
|
||||
|
||||
import type { Transition } from 'framer-motion';
|
||||
|
||||
|
|
@ -11,24 +11,16 @@ const transition = { duration: 0.3, delay: 0.5 } as Transition;
|
|||
|
||||
export const Tags: React.FC = () => {
|
||||
const { web } = useConfig();
|
||||
const { queryLocation, queryTarget, queryType, queryGroup } = useLGState();
|
||||
const { getDirective } = useLGMethods();
|
||||
const form = useFormState(s => s.form);
|
||||
const getDirective = useFormState(s => s.getDirective);
|
||||
|
||||
const selectedDirective = useMemo(() => {
|
||||
if (queryType.value === '') {
|
||||
return null;
|
||||
}
|
||||
const directive = getDirective(queryType.value);
|
||||
if (directive !== null) {
|
||||
return directive;
|
||||
}
|
||||
return null;
|
||||
return getDirective();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryType.value, queryGroup.value, getDirective]);
|
||||
}, [form.queryType, getDirective]);
|
||||
|
||||
const targetBg = useToken('colors', 'teal.600');
|
||||
const queryBg = useToken('colors', 'cyan.500');
|
||||
const vrfBg = useToken('colors', 'blue.500');
|
||||
|
||||
const animateLeft = useBreakpointValue({
|
||||
base: { opacity: 1, x: 0 },
|
||||
|
|
@ -37,12 +29,12 @@ export const Tags: React.FC = () => {
|
|||
xl: { opacity: 1, x: 0 },
|
||||
});
|
||||
|
||||
const animateCenter = useBreakpointValue({
|
||||
base: { opacity: 1 },
|
||||
md: { opacity: 1 },
|
||||
lg: { opacity: 1 },
|
||||
xl: { opacity: 1 },
|
||||
});
|
||||
// const animateCenter = useBreakpointValue({
|
||||
// base: { opacity: 1 },
|
||||
// md: { opacity: 1 },
|
||||
// lg: { opacity: 1 },
|
||||
// xl: { opacity: 1 },
|
||||
// });
|
||||
|
||||
const animateRight = useBreakpointValue({
|
||||
base: { opacity: 1, x: 0 },
|
||||
|
|
@ -58,12 +50,12 @@ export const Tags: React.FC = () => {
|
|||
xl: { opacity: 0, x: '-100%' },
|
||||
});
|
||||
|
||||
const initialCenter = useBreakpointValue({
|
||||
base: { opacity: 0 },
|
||||
md: { opacity: 0 },
|
||||
lg: { opacity: 0 },
|
||||
xl: { opacity: 0 },
|
||||
});
|
||||
// const initialCenter = useBreakpointValue({
|
||||
// base: { opacity: 0 },
|
||||
// md: { opacity: 0 },
|
||||
// lg: { opacity: 0 },
|
||||
// xl: { opacity: 0 },
|
||||
// });
|
||||
|
||||
const initialRight = useBreakpointValue({
|
||||
base: { opacity: 0, x: '100%' },
|
||||
|
|
@ -83,7 +75,7 @@ export const Tags: React.FC = () => {
|
|||
>
|
||||
<Stack isInline align="center" justify="center" mt={4} flexWrap="wrap">
|
||||
<AnimatePresence>
|
||||
{queryLocation.value && (
|
||||
{form.queryLocation.length > 0 && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={initialLeft}
|
||||
|
|
@ -95,32 +87,19 @@ export const Tags: React.FC = () => {
|
|||
bg={queryBg}
|
||||
label={web.text.queryType}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
value={selectedDirective?.value.name ?? 'None'}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={initialCenter}
|
||||
animate={animateCenter}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
>
|
||||
<Label
|
||||
bg={targetBg}
|
||||
value={queryTarget.value}
|
||||
label={web.text.queryTarget}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
value={selectedDirective?.name ?? 'None'}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={initialRight}
|
||||
animate={animateRight}
|
||||
exit={{ opacity: 0, x: '100%' }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
>
|
||||
<Label
|
||||
bg={vrfBg}
|
||||
label={web.text.queryGroup}
|
||||
value={queryGroup.value}
|
||||
bg={targetBg}
|
||||
value={form.queryTarget}
|
||||
label={web.text.queryTarget}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import type { State } from '@hookstate/core';
|
||||
import type { ButtonProps } from '@chakra-ui/react';
|
||||
import type { UseQueryResult } from 'react-query';
|
||||
import type { Device } from '~/types';
|
||||
|
||||
export interface TResultHeader {
|
||||
title: string;
|
||||
|
|
@ -17,13 +15,9 @@ export interface TFormattedError {
|
|||
message: string;
|
||||
}
|
||||
|
||||
export interface TResult {
|
||||
export interface ResultProps {
|
||||
index: number;
|
||||
device: Device;
|
||||
queryGroup: string;
|
||||
queryTarget: string;
|
||||
queryLocation: string;
|
||||
queryType: string;
|
||||
}
|
||||
|
||||
export type TErrorLevels = 'success' | 'warning' | 'error';
|
||||
|
|
@ -35,18 +29,3 @@ export interface TCopyButton extends ButtonProps {
|
|||
export interface TRequeryButton extends ButtonProps {
|
||||
requery: UseQueryResult<QueryResponse>['refetch'];
|
||||
}
|
||||
|
||||
export type TUseResults = {
|
||||
firstOpen: number | null;
|
||||
locations: { [k: string]: { complete: boolean; open: boolean; index: number } };
|
||||
};
|
||||
|
||||
export type TUseResultsMethods = {
|
||||
toggle(loc: string): void;
|
||||
setComplete(loc: string): void;
|
||||
getOpen(): number[];
|
||||
};
|
||||
|
||||
export type UseResultsReturn = {
|
||||
results: State<TUseResults>;
|
||||
} & TUseResultsMethods;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './select';
|
||||
export type { TOptions } from './types';
|
||||
|
|
|
|||
25
hyperglass/ui/components/select/option.tsx
Normal file
25
hyperglass/ui/components/select/option.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@ import { createContext, useContext, useMemo } from 'react';
|
|||
import ReactSelect from 'react-select';
|
||||
import { chakra, useDisclosure } from '@chakra-ui/react';
|
||||
import { useColorMode } from '~/context';
|
||||
import { Option } from './option';
|
||||
import {
|
||||
useRSTheme,
|
||||
useMenuStyle,
|
||||
|
|
@ -17,16 +18,16 @@ import {
|
|||
useIndicatorSeparatorStyle,
|
||||
} from './styles';
|
||||
|
||||
import type { TSelectOption } from '~/types';
|
||||
import type { SingleOption } from '~/types';
|
||||
import type { TSelectBase, TSelectContext, TReactSelectChakra } from './types';
|
||||
|
||||
const SelectContext = createContext<TSelectContext>(Object());
|
||||
const SelectContext = createContext<TSelectContext>({} as TSelectContext);
|
||||
export const useSelectContext = (): TSelectContext => useContext(SelectContext);
|
||||
|
||||
const ReactSelectChakra = chakra<typeof ReactSelect, TReactSelectChakra>(ReactSelect);
|
||||
|
||||
export const Select: React.FC<TSelectBase> = (props: TSelectBase) => {
|
||||
const { options, multi, onSelect, isError = false, ...rest } = props;
|
||||
const { options, multi, onSelect, isError = false, components, ...rest } = props;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
|
|
@ -36,7 +37,7 @@ export const Select: React.FC<TSelectBase> = (props: TSelectBase) => {
|
|||
[colorMode, isError, isOpen],
|
||||
);
|
||||
|
||||
const defaultOnChange = (changed: TSelectOption | TSelectOption[]) => {
|
||||
const defaultOnChange = (changed: SingleOption | SingleOption[]) => {
|
||||
if (!Array.isArray(changed)) {
|
||||
changed = [changed];
|
||||
}
|
||||
|
|
@ -61,6 +62,7 @@ export const Select: React.FC<TSelectBase> = (props: TSelectBase) => {
|
|||
options={options}
|
||||
isMulti={multi}
|
||||
theme={rsTheme}
|
||||
components={{ Option, ...components }}
|
||||
styles={{
|
||||
menuPortal,
|
||||
multiValue,
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ import type {
|
|||
Styles as RSStyles,
|
||||
} from 'react-select';
|
||||
import type { BoxProps } from '@chakra-ui/react';
|
||||
import type { Theme, TSelectOption, TSelectOptionMulti, TSelectOptionGroup } from '~/types';
|
||||
import type { Theme, SingleOption, OptionGroup } from '~/types';
|
||||
|
||||
export interface TSelectState {
|
||||
[k: string]: string[];
|
||||
}
|
||||
|
||||
export type TOptions = Array<TSelectOptionGroup | TSelectOption>;
|
||||
export type TOptions = Array<SingleOption | OptionGroup>;
|
||||
|
||||
export type TReactSelectChakra = Omit<IReactSelect, 'isMulti' | 'onSelect' | 'onChange'> &
|
||||
Omit<BoxProps, 'onChange' | 'onSelect'>;
|
||||
|
|
@ -31,8 +31,8 @@ export interface TSelectBase extends TReactSelectChakra {
|
|||
isError?: boolean;
|
||||
options: TOptions;
|
||||
required?: boolean;
|
||||
onSelect?: (s: TSelectOption[]) => void;
|
||||
onChange?: (c: TSelectOption | TSelectOptionMulti) => void;
|
||||
onSelect?: (s: SingleOption[]) => void;
|
||||
onChange?: (c: SingleOption | SingleOption[]) => void;
|
||||
colorScheme?: Theme.ColorNames;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ import { FiSearch } from '@meronex/icons/fi';
|
|||
import { useFormContext } from 'react-hook-form';
|
||||
import { If, ResolvedTarget } from '~/components';
|
||||
import { useMobile, useColorValue } from '~/context';
|
||||
import { useLGState, useLGMethods } from '~/hooks';
|
||||
import { useFormState } from '~/hooks';
|
||||
|
||||
import type { IconButtonProps } from '@chakra-ui/react';
|
||||
import type { TSubmitButton, TRSubmitButton } from './types';
|
||||
import type { SubmitButtonProps, ResponsiveSubmitButtonProps } from './types';
|
||||
|
||||
const _SubmitIcon: React.ForwardRefRenderFunction<
|
||||
HTMLButtonElement,
|
||||
|
|
@ -47,8 +47,8 @@ const SubmitIcon = forwardRef<HTMLButtonElement, Omit<IconButtonProps, 'aria-lab
|
|||
/**
|
||||
* Mobile Submit Button
|
||||
*/
|
||||
const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
|
||||
const { children, isOpen, onClose, onChange } = props;
|
||||
const MSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => {
|
||||
const { children, isOpen, onClose } = props;
|
||||
const bg = useColorValue('white', 'gray.900');
|
||||
return (
|
||||
<>
|
||||
|
|
@ -66,7 +66,7 @@ const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
|
|||
<ModalContent bg={bg}>
|
||||
<ModalCloseButton />
|
||||
<ModalBody px={4} py={10}>
|
||||
{isOpen && <ResolvedTarget setTarget={onChange} errorClose={onClose} />}
|
||||
{isOpen && <ResolvedTarget errorClose={onClose} />}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
|
@ -77,8 +77,8 @@ const MSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
|
|||
/**
|
||||
* Desktop Submit Button
|
||||
*/
|
||||
const DSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
|
||||
const { children, isOpen, onClose, onChange } = props;
|
||||
const DSubmitButton = (props: ResponsiveSubmitButtonProps): JSX.Element => {
|
||||
const { children, isOpen, onClose } = props;
|
||||
const bg = useColorValue('white', 'gray.900');
|
||||
return (
|
||||
<Popover isOpen={isOpen} onClose={onClose} closeOnBlur={false}>
|
||||
|
|
@ -86,19 +86,24 @@ const DSubmitButton: React.FC<TRSubmitButton> = (props: TRSubmitButton) => {
|
|||
<PopoverContent bg={bg}>
|
||||
<PopoverArrow bg={bg} />
|
||||
<PopoverCloseButton />
|
||||
<PopoverBody p={6}>
|
||||
{isOpen && <ResolvedTarget setTarget={onChange} errorClose={onClose} />}
|
||||
</PopoverBody>
|
||||
<PopoverBody p={6}>{isOpen && <ResolvedTarget errorClose={onClose} />}</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubmitButton: React.FC<TSubmitButton> = (props: TSubmitButton) => {
|
||||
const { handleChange } = props;
|
||||
export const SubmitButton = (props: SubmitButtonProps): JSX.Element => {
|
||||
const isMobile = useMobile();
|
||||
const { resolvedIsOpen, btnLoading } = useLGState();
|
||||
const { resolvedClose, resetForm } = useLGMethods();
|
||||
const loading = useFormState(s => s.loading);
|
||||
const {
|
||||
resolvedIsOpen,
|
||||
resolvedClose,
|
||||
reset: resetForm,
|
||||
} = useFormState(({ resolvedIsOpen, resolvedClose, reset }) => ({
|
||||
resolvedIsOpen,
|
||||
resolvedClose,
|
||||
reset,
|
||||
}));
|
||||
|
||||
const { reset } = useFormContext();
|
||||
|
||||
|
|
@ -111,13 +116,13 @@ export const SubmitButton: React.FC<TSubmitButton> = (props: TSubmitButton) => {
|
|||
return (
|
||||
<>
|
||||
<If c={isMobile}>
|
||||
<MSubmitButton isOpen={resolvedIsOpen.value} onClose={handleClose} onChange={handleChange}>
|
||||
<SubmitIcon isLoading={btnLoading.value} />
|
||||
<MSubmitButton isOpen={resolvedIsOpen} onClose={handleClose}>
|
||||
<SubmitIcon isLoading={loading} {...props} />
|
||||
</MSubmitButton>
|
||||
</If>
|
||||
<If c={!isMobile}>
|
||||
<DSubmitButton isOpen={resolvedIsOpen.value} onClose={handleClose} onChange={handleChange}>
|
||||
<SubmitIcon isLoading={btnLoading.value} />
|
||||
<DSubmitButton isOpen={resolvedIsOpen} onClose={handleClose}>
|
||||
<SubmitIcon isLoading={loading} {...props} />
|
||||
</DSubmitButton>
|
||||
</If>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import type { IconButtonProps } from '@chakra-ui/react';
|
||||
import type { OnChangeArgs } from '~/types';
|
||||
|
||||
export interface TSubmitButton extends Omit<IconButtonProps, 'aria-label'> {
|
||||
handleChange(e: OnChangeArgs): void;
|
||||
}
|
||||
export type SubmitButtonProps = Omit<IconButtonProps, 'aria-label'>;
|
||||
|
||||
export interface TRSubmitButton {
|
||||
export interface ResponsiveSubmitButtonProps {
|
||||
isOpen: boolean;
|
||||
onClose(): void;
|
||||
onChange(e: OnChangeArgs): void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
/* eslint react/display-name: off */
|
||||
import { Box, forwardRef } from '@chakra-ui/react';
|
||||
import { chakra, Box, forwardRef } from '@chakra-ui/react';
|
||||
import { motion, isValidMotionProp } from 'framer-motion';
|
||||
|
||||
import type { BoxProps } from '@chakra-ui/react';
|
||||
import type { CustomDomComponent, Transition, MotionProps } from 'framer-motion';
|
||||
|
||||
type MCComponent = Parameters<typeof chakra>[0];
|
||||
type MCOptions = Parameters<typeof chakra>[1];
|
||||
type MakeMotionProps<P extends BoxProps> = React.PropsWithChildren<
|
||||
Omit<P, 'transition'> & Omit<MotionProps, 'transition'> & { transition?: Transition }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Combined Chakra + Framer Motion component.
|
||||
|
|
@ -16,3 +22,18 @@ export const AnimatedDiv = motion(
|
|||
return <Box ref={ref} {...chakraProps} />;
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Combine `chakra` and `motion` factories.
|
||||
*
|
||||
* @param component Component or string
|
||||
* @param options `chakra` options
|
||||
* @returns Chakra component with motion props.
|
||||
*/
|
||||
export function motionChakra<P extends BoxProps = BoxProps>(
|
||||
component: MCComponent,
|
||||
options?: MCOptions,
|
||||
): CustomDomComponent<MakeMotionProps<P>> {
|
||||
// @ts-expect-error I don't know how to fix this.
|
||||
return motion<P>(chakra<MCComponent, P>(component, options));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,6 @@
|
|||
import type { State } from '@hookstate/core';
|
||||
import type { Config, FormData } from '~/types';
|
||||
import type { Config } from '~/types';
|
||||
|
||||
export interface THyperglassProvider {
|
||||
config: Config;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TGlobalState {
|
||||
isSubmitting: boolean;
|
||||
formData: FormData;
|
||||
}
|
||||
|
||||
export interface TUseGlobalState {
|
||||
isSubmitting: State<TGlobalState['isSubmitting']>;
|
||||
formData: State<TGlobalState['formData']>;
|
||||
resetForm(): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ export * from './useBooleanValue';
|
|||
export * from './useDevice';
|
||||
export * from './useDirective';
|
||||
export * from './useDNSQuery';
|
||||
export * from './useFormState';
|
||||
export * from './useGoogleAnalytics';
|
||||
export * from './useGreeting';
|
||||
export * from './useHyperglassConfig';
|
||||
export * from './useLGQuery';
|
||||
export * from './useLGState';
|
||||
export * from './useOpposingColor';
|
||||
export * from './useStrf';
|
||||
export * from './useTableToString';
|
||||
|
|
|
|||
|
|
@ -1,22 +1,33 @@
|
|||
import type { State } from '@hookstate/core';
|
||||
import type { UseQueryOptions } from 'react-query';
|
||||
import type * as ReactGA from 'react-ga';
|
||||
import type { Device, Families, TFormQuery, TSelectOption, Directive } from '~/types';
|
||||
import type { Device, TFormQuery } from '~/types';
|
||||
|
||||
export type LGQueryKey = [string, TFormQuery];
|
||||
export type DNSQueryKey = [string, { target: string | null; family: 4 | 6 }];
|
||||
|
||||
export type LGQueryOptions = Omit<
|
||||
UseQueryOptions<QueryResponse, Response | QueryResponse | Error, QueryResponse, LGQueryKey>,
|
||||
| 'queryKey'
|
||||
| 'queryFn'
|
||||
| 'cacheTime'
|
||||
| 'refetchOnWindowFocus'
|
||||
| 'refetchInterval'
|
||||
| 'refetchOnMount'
|
||||
>;
|
||||
|
||||
export interface TOpposingOptions {
|
||||
light?: string;
|
||||
dark?: string;
|
||||
}
|
||||
|
||||
export type TUseGreetingReturn = {
|
||||
ack: State<boolean>;
|
||||
isOpen: State<boolean>;
|
||||
export interface UseGreeting {
|
||||
isAck: boolean;
|
||||
isOpen: boolean;
|
||||
greetingReady: boolean;
|
||||
ack(value: boolean): void;
|
||||
open(): void;
|
||||
close(): void;
|
||||
greetingReady(): boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type TUseDevice = (
|
||||
/**
|
||||
|
|
@ -25,50 +36,6 @@ export type TUseDevice = (
|
|||
deviceId: string,
|
||||
) => Device;
|
||||
|
||||
export interface TSelections {
|
||||
queryLocation: TSelectOption[] | [];
|
||||
queryType: TSelectOption | null;
|
||||
queryGroup: TSelectOption | null;
|
||||
}
|
||||
|
||||
export interface TMethodsExtension {
|
||||
getResponse(d: string): QueryResponse | null;
|
||||
resolvedClose(): void;
|
||||
resolvedOpen(): void;
|
||||
formReady(): boolean;
|
||||
resetForm(): void;
|
||||
stateExporter<O extends unknown>(o: O): O | null;
|
||||
getDirective(n: string): Nullable<State<Directive>>;
|
||||
}
|
||||
|
||||
export type TLGState = {
|
||||
queryGroup: string;
|
||||
families: Families;
|
||||
queryTarget: string;
|
||||
btnLoading: boolean;
|
||||
isSubmitting: boolean;
|
||||
displayTarget: string;
|
||||
directive: Directive | null;
|
||||
queryType: string;
|
||||
queryLocation: string[];
|
||||
availableGroups: string[];
|
||||
availableTypes: Directive[];
|
||||
resolvedIsOpen: boolean;
|
||||
selections: TSelections;
|
||||
responses: { [d: string]: QueryResponse };
|
||||
};
|
||||
|
||||
export type TLGStateHandlers = {
|
||||
exportState<S extends unknown | null>(s: S): S | null;
|
||||
getResponse(d: string): QueryResponse | null;
|
||||
resolvedClose(): void;
|
||||
resolvedOpen(): void;
|
||||
formReady(): boolean;
|
||||
resetForm(): void;
|
||||
stateExporter<O extends unknown>(o: O): O | null;
|
||||
getDirective(n: string): Nullable<State<Directive>>;
|
||||
};
|
||||
|
||||
export type UseStrfArgs = { [k: string]: unknown } | string;
|
||||
|
||||
export type TTableToStringFormatter =
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useLGMethods, useLGState } from './useLGState';
|
||||
import { useFormState } from './useFormState';
|
||||
|
||||
import type { Directive } from '~/types';
|
||||
|
||||
export function useDirective(): Nullable<Directive> {
|
||||
const { queryType, queryGroup } = useLGState();
|
||||
const { getDirective } = useLGMethods();
|
||||
const { getDirective, form } = useFormState(({ getDirective, form }) => ({ getDirective, form }));
|
||||
|
||||
return useMemo((): Nullable<Directive> => {
|
||||
if (queryType.value === '') {
|
||||
if (form.queryType === '') {
|
||||
return null;
|
||||
}
|
||||
const directive = getDirective(queryType.value);
|
||||
if (directive !== null) {
|
||||
return directive.value;
|
||||
}
|
||||
return null;
|
||||
const directive = getDirective();
|
||||
return directive;
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryType.value, queryGroup.value, getDirective]);
|
||||
}, [form.queryType, getDirective]);
|
||||
}
|
||||
|
|
|
|||
235
hyperglass/ui/hooks/useFormState.ts
Normal file
235
hyperglass/ui/hooks/useFormState.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -1,23 +1,37 @@
|
|||
import create from 'zustand';
|
||||
import { useCallback } from 'react';
|
||||
import { createState, useState } from '@hookstate/core';
|
||||
import * as ReactGA from 'react-ga';
|
||||
|
||||
import type { GAEffect, GAReturn } from './types';
|
||||
|
||||
const enabledState = createState<boolean>(false);
|
||||
interface EnabledState {
|
||||
enabled: boolean;
|
||||
enable(): void;
|
||||
disable(): void;
|
||||
}
|
||||
|
||||
const useEnabled = create<EnabledState>(set => ({
|
||||
enabled: false,
|
||||
enable() {
|
||||
set({ enabled: true });
|
||||
},
|
||||
disable() {
|
||||
set({ enabled: false });
|
||||
},
|
||||
}));
|
||||
|
||||
export function useGoogleAnalytics(): GAReturn {
|
||||
const enabled = useState<boolean>(enabledState);
|
||||
const { enabled, enable } = useEnabled(({ enable, enabled }) => ({ enable, enabled }));
|
||||
|
||||
const runEffect = useCallback(
|
||||
(effect: GAEffect): void => {
|
||||
if (typeof window !== 'undefined' && enabled.value) {
|
||||
if (typeof window !== 'undefined' && enabled) {
|
||||
if (typeof effect === 'function') {
|
||||
effect(ReactGA);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled.value],
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const trackEvent = useCallback(
|
||||
|
|
@ -77,7 +91,7 @@ export function useGoogleAnalytics(): GAReturn {
|
|||
return;
|
||||
}
|
||||
|
||||
enabled.set(true);
|
||||
enable();
|
||||
|
||||
const initializeOpts = { titleCase: false } as ReactGA.InitializeOptions;
|
||||
|
||||
|
|
@ -89,7 +103,7 @@ export function useGoogleAnalytics(): GAReturn {
|
|||
ga.initialize(trackingId, initializeOpts);
|
||||
});
|
||||
},
|
||||
[runEffect, enabled],
|
||||
[runEffect, enable],
|
||||
);
|
||||
|
||||
return { trackEvent, trackModal, trackPage, initialize, ga: ReactGA };
|
||||
|
|
|
|||
|
|
@ -1,45 +1,54 @@
|
|||
import { createState, useState } from '@hookstate/core';
|
||||
import { Persistence } from '@hookstate/persistence';
|
||||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { useConfig } from '~/context';
|
||||
import { withDev } from '~/util';
|
||||
|
||||
import type { TUseGreetingReturn } from './types';
|
||||
import type { StateSelector, EqualityChecker } from 'zustand';
|
||||
import type { UseGreeting } from './types';
|
||||
|
||||
const ackState = createState<boolean>(false);
|
||||
const openState = createState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Hook to manage the greeting, a.k.a. the popup at config path web.greeting.
|
||||
*/
|
||||
export function useGreeting(): TUseGreetingReturn {
|
||||
const ack = useState<boolean>(ackState);
|
||||
const isOpen = useState<boolean>(openState);
|
||||
const { web } = useConfig();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
ack.attach(Persistence('hyperglass-greeting'));
|
||||
export function useGreeting(): UseGreeting;
|
||||
export function useGreeting<U extends ValueOf<UseGreeting>>(
|
||||
selector: StateSelector<UseGreeting, U>,
|
||||
equalityFn?: EqualityChecker<U>,
|
||||
): U;
|
||||
export function useGreeting<U extends Partial<UseGreeting>>(
|
||||
selector: StateSelector<UseGreeting, U>,
|
||||
equalityFn?: EqualityChecker<U>,
|
||||
): U;
|
||||
export function useGreeting<U extends UseGreeting>(
|
||||
selector?: StateSelector<UseGreeting, U>,
|
||||
equalityFn?: EqualityChecker<U>,
|
||||
): U {
|
||||
const {
|
||||
web: {
|
||||
greeting: { required },
|
||||
},
|
||||
} = useConfig();
|
||||
const storeFn = create<UseGreeting>(
|
||||
persist(
|
||||
withDev<UseGreeting>(
|
||||
set => ({
|
||||
isOpen: false,
|
||||
isAck: false,
|
||||
greetingReady: false,
|
||||
ack(isAck: boolean): void {
|
||||
const greetingReady = isAck ? true : !required ? true : false;
|
||||
set(() => ({ isAck, greetingReady, isOpen: false }));
|
||||
},
|
||||
open(): void {
|
||||
set(() => ({ isOpen: true }));
|
||||
},
|
||||
close(): void {
|
||||
set(() => ({ isOpen: false }));
|
||||
},
|
||||
}),
|
||||
'useGreeting',
|
||||
),
|
||||
{ name: 'hyperglass-greeting' },
|
||||
),
|
||||
);
|
||||
if (typeof selector === 'function') {
|
||||
return storeFn<U>(selector, equalityFn);
|
||||
}
|
||||
|
||||
function open() {
|
||||
return isOpen.set(true);
|
||||
}
|
||||
function close() {
|
||||
return isOpen.set(false);
|
||||
}
|
||||
|
||||
function greetingReady(): boolean {
|
||||
if (ack.get()) {
|
||||
// If the acknowledgement is already set, no further evaluation is needed.
|
||||
return true;
|
||||
} else if (!web.greeting.required && !ack.get()) {
|
||||
// If the acknowledgement is not set, but is also not required, then pass.
|
||||
return true;
|
||||
} else if (web.greeting.required && !ack.get()) {
|
||||
// If the acknowledgement is not set, but is required, then fail.
|
||||
return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return { ack, isOpen, greetingReady, open, close };
|
||||
return storeFn() as U;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ import { fetchWithTimeout } from '~/util';
|
|||
|
||||
import type { QueryFunction, QueryFunctionContext, QueryObserverResult } from 'react-query';
|
||||
import type { TFormQuery } from '~/types';
|
||||
import type { LGQueryKey } from './types';
|
||||
import type { LGQueryKey, LGQueryOptions } from './types';
|
||||
|
||||
/**
|
||||
* Custom hook handle submission of a query to the hyperglass backend.
|
||||
*/
|
||||
export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse> {
|
||||
export function useLGQuery(
|
||||
query: TFormQuery,
|
||||
options: LGQueryOptions = {} as LGQueryOptions,
|
||||
): QueryObserverResult<QueryResponse> {
|
||||
const { requestTimeout, cache } = useConfig();
|
||||
const controller = useMemo(() => new AbortController(), []);
|
||||
|
||||
|
|
@ -23,14 +26,13 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse
|
|||
dimension1: query.queryLocation,
|
||||
dimension2: query.queryTarget,
|
||||
dimension3: query.queryType,
|
||||
dimension4: query.queryGroup,
|
||||
});
|
||||
|
||||
const runQuery: QueryFunction<QueryResponse, LGQueryKey> = async (
|
||||
ctx: QueryFunctionContext<LGQueryKey>,
|
||||
): Promise<QueryResponse> => {
|
||||
const [url, data] = ctx.queryKey;
|
||||
const { queryLocation, queryTarget, queryType, queryGroup } = data;
|
||||
const { queryLocation, queryTarget, queryType } = data;
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
|
|
@ -40,7 +42,6 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse
|
|||
queryLocation,
|
||||
queryTarget,
|
||||
queryType,
|
||||
queryGroup,
|
||||
}),
|
||||
mode: 'cors',
|
||||
},
|
||||
|
|
@ -73,5 +74,6 @@ export function useLGQuery(query: TFormQuery): QueryObserverResult<QueryResponse
|
|||
refetchInterval: false,
|
||||
// Don't refetch on component remount.
|
||||
refetchOnMount: false,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
8
hyperglass/ui/package.json
vendored
8
hyperglass/ui/package.json
vendored
|
|
@ -16,12 +16,11 @@
|
|||
"browserslist": "> 0.25%, not dead",
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^1.6.3",
|
||||
"@choc-ui/chakra-autocomplete": "^4.5.10",
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@hookform/devtools": "^3.1.0",
|
||||
"@hookform/resolvers": "^2.5.1",
|
||||
"@hookstate/core": "^3.0.7",
|
||||
"@hookstate/persistence": "^3.0.0",
|
||||
"@meronex/icons": "^4.0.0",
|
||||
"dagre": "^0.8.5",
|
||||
"dayjs": "^1.10.4",
|
||||
|
|
@ -29,6 +28,7 @@
|
|||
"lodash": "^4.17.21",
|
||||
"next": "^11.1.2",
|
||||
"palette-by-numbers": "^0.1.5",
|
||||
"plur": "^4.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-countdown": "^2.2.1",
|
||||
"react-device-detect": "^1.15.0",
|
||||
|
|
@ -43,10 +43,10 @@
|
|||
"react-table": "^7.7.0",
|
||||
"remark-gfm": "^1.0.0",
|
||||
"string-format": "^2.0.0",
|
||||
"vest": "^3.2.3"
|
||||
"vest": "^3.2.3",
|
||||
"zustand": "^3.5.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookstate/devtools": "^3.0.0",
|
||||
"@types/dagre": "^0.7.44",
|
||||
"@types/node": "^14.14.41",
|
||||
"@types/react": "^17.0.3",
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
|||
|
||||
import type { AppProps } from 'next/app';
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
require('@hookstate/devtools');
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = (props: AppProps): JSX.Element => {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,17 @@
|
|||
import { State } from '@hookstate/core';
|
||||
|
||||
export type TSelectOptionBase = {
|
||||
type AnyOption = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type SingleOption = AnyOption & {
|
||||
value: string;
|
||||
group?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type TSelectOption = TSelectOptionBase | null;
|
||||
|
||||
export type TSelectOptionMulti = TSelectOptionBase[] | null;
|
||||
|
||||
export type TSelectOptionState = State<TSelectOption>;
|
||||
|
||||
export type TSelectOptionGroup = {
|
||||
label: string;
|
||||
options: TSelectOption[];
|
||||
export type OptionGroup = AnyOption & {
|
||||
options: SingleOption[];
|
||||
};
|
||||
|
||||
export type SelectOption<T extends unknown = unknown> = (SingleOption | OptionGroup) & { data: T };
|
||||
|
||||
export type OnChangeArgs = { field: string; value: string | string[] };
|
||||
|
||||
export type Families = [4] | [6] | [4, 6] | [];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export interface FormData {
|
|||
queryLocation: string[];
|
||||
queryType: string;
|
||||
queryTarget: string;
|
||||
queryGroup: string;
|
||||
}
|
||||
|
||||
export interface TFormQuery extends Omit<FormData, 'queryLocation'> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import type { State } from '@hookstate/core';
|
||||
import type { FormData, TStringTableData, TQueryResponseString } from './data';
|
||||
import type { TSelectOption } from './common';
|
||||
import type { QueryContent, DirectiveSelect, Directive } from './config';
|
||||
|
||||
export function isString(a: unknown): a is string {
|
||||
|
|
@ -31,24 +29,6 @@ export function isQueryContent(content: unknown): content is QueryContent {
|
|||
return isObject(content) && 'content' in content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an object is a Select option.
|
||||
*/
|
||||
export function isSelectOption(a: unknown): a is NonNullable<TSelectOption> {
|
||||
return isObject(a) && 'label' in a && 'value' in a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an object is a HookState Proxy.
|
||||
*/
|
||||
export function isState<S>(a: unknown): a is State<NonNullable<S>> {
|
||||
if (isObject(a) && 'get' in a && 'set' in a && 'promised' in a) {
|
||||
const obj = a as { get: never; set: never; promised: never };
|
||||
return typeof obj.get === 'function' && typeof obj.set === 'function';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a form field name is a valid form key name.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from './common';
|
||||
export * from './config';
|
||||
export * from './state';
|
||||
export * from './theme';
|
||||
|
|
|
|||
20
hyperglass/ui/util/state.ts
Normal file
20
hyperglass/ui/util/state.ts
Normal 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;
|
||||
}
|
||||
65
hyperglass/ui/yarn.lock
vendored
65
hyperglass/ui/yarn.lock
vendored
|
|
@ -741,6 +741,13 @@
|
|||
dependencies:
|
||||
"@chakra-ui/utils" "1.8.0"
|
||||
|
||||
"@choc-ui/chakra-autocomplete@^4.5.10":
|
||||
version "4.5.10"
|
||||
resolved "https://registry.yarnpkg.com/@choc-ui/chakra-autocomplete/-/chakra-autocomplete-4.5.10.tgz#0d98043c001e9aef26f7400201a2fda24cb301c3"
|
||||
integrity sha512-nxEdtV2x5pzU0gOkN4inNVeRRxTjrY0JJ40MYWEAgL6mFM8AogzVSU2X/l8uutEIVz0P+hu60/96Vhu10eNxOA==
|
||||
dependencies:
|
||||
react-nanny "^2.9.0"
|
||||
|
||||
"@emotion/babel-plugin@^11.3.0":
|
||||
version "11.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.3.0.tgz#3a16850ba04d8d9651f07f3fb674b3436a4fb9d7"
|
||||
|
|
@ -946,24 +953,6 @@
|
|||
resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-2.5.1.tgz#09f2228c9061c350819881dc11e4f65af90c5a1a"
|
||||
integrity sha512-dNChuOJ7ker2ZALGhXO6KjeycwnBGcSslGsREgqTDSiBOgbEysupfGTJKzQsb0sXpNhrZLCGvQcT+qPmCdBEMw==
|
||||
|
||||
"@hookstate/core@^3.0.7":
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@hookstate/core/-/core-3.0.7.tgz#aa91c3ca93771abda4a9729302c088d35bb7abaa"
|
||||
integrity sha512-9aOGwD4tezrXJ7AkiPg0MJ8d9TiRCusf537zPOV4vOJmNn/tyMDMDxd1T4OlqKvNurVTzs9BEIMOuq5OduV7/w==
|
||||
|
||||
"@hookstate/devtools@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@hookstate/devtools/-/devtools-3.0.0.tgz#9b289503112f0f95221d0c86e602ada7793bbd3a"
|
||||
integrity sha512-jXI9e+4+dr0FbdtHhQPa0/SmRwLM7twMU0Qf87AgM3DqqFDV9dRPD56jPXaTh8xD1Bt2R72TmGmqdkDhdKG8AQ==
|
||||
dependencies:
|
||||
redux "4.0.5"
|
||||
redux-devtools-extension "2.13.8"
|
||||
|
||||
"@hookstate/persistence@^3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@hookstate/persistence/-/persistence-3.0.0.tgz#973b30d3a3bb1b29f4f59b1afe0d914e71a23882"
|
||||
integrity sha512-xiN22IW0Wjw/uTV1OEUpTtqB8DAJyRobYR3+iv1TyMf72o98KpaZqQrLPvXkfrjlZac5s052BypjpkttX/Cmhw==
|
||||
|
||||
"@humanwhocodes/config-array@^0.5.0":
|
||||
version "0.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
|
||||
|
|
@ -3649,6 +3638,11 @@ ipaddr.js@1.9.1:
|
|||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
|
||||
|
||||
irregular-plurals@^3.2.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/irregular-plurals/-/irregular-plurals-3.3.0.tgz#67d0715d4361a60d9fd9ee80af3881c631a31ee2"
|
||||
integrity sha512-MVBLKUTangM3EfRPFROhmWQQKRDsrgI83J8GS3jXy+OwYqiR2/aoWndYQ5416jLE3uaGgLH7ncme3X9y09gZ3g==
|
||||
|
||||
is-alphabetical@^1.0.0:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d"
|
||||
|
|
@ -4844,6 +4838,13 @@ platform@1.3.6:
|
|||
resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7"
|
||||
integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==
|
||||
|
||||
plur@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/plur/-/plur-4.0.0.tgz#729aedb08f452645fe8c58ef115bf16b0a73ef84"
|
||||
integrity sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==
|
||||
dependencies:
|
||||
irregular-plurals "^3.2.0"
|
||||
|
||||
pnp-webpack-plugin@1.6.4:
|
||||
version "1.6.4"
|
||||
resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"
|
||||
|
|
@ -5158,6 +5159,11 @@ react-markdown@^5.0.3:
|
|||
unist-util-visit "^2.0.0"
|
||||
xtend "^4.0.1"
|
||||
|
||||
react-nanny@^2.9.0:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/react-nanny/-/react-nanny-2.10.0.tgz#50543fbbdd4daebb925b9603dae219da7ab7cd93"
|
||||
integrity sha512-MmrcxrVQT5cimC0ZUYy7ZRl+t9P5hWM7E2LkFWSrWPj0C5MOlXfz4Oeauu3hImIyvoa0SunpLh22Go0L71xAbA==
|
||||
|
||||
react-query@^3.16.0:
|
||||
version "3.16.0"
|
||||
resolved "https://registry.yarnpkg.com/react-query/-/react-query-3.16.0.tgz#8de1556aabb3d200d0f8eeb74ce2b0b3dd0a0a51"
|
||||
|
|
@ -5299,19 +5305,6 @@ readdirp@~3.5.0:
|
|||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
redux-devtools-extension@2.13.8:
|
||||
version "2.13.8"
|
||||
resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz#37b982688626e5e4993ff87220c9bbb7cd2d96e1"
|
||||
integrity sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg==
|
||||
|
||||
redux@4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
|
||||
integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==
|
||||
dependencies:
|
||||
loose-envify "^1.4.0"
|
||||
symbol-observable "^1.2.0"
|
||||
|
||||
redux@^4.0.0, redux@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4"
|
||||
|
|
@ -5841,11 +5834,6 @@ supports-color@^8.0.0:
|
|||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
symbol-observable@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
|
||||
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
|
||||
|
||||
table@^6.0.9:
|
||||
version "6.7.1"
|
||||
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
|
||||
|
|
@ -6354,6 +6342,11 @@ yocto-queue@^0.1.0:
|
|||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
||||
zustand@^3.5.10:
|
||||
version "3.5.10"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-3.5.10.tgz#d2622efd64530ffda285ee5b13ff645b68ab0faf"
|
||||
integrity sha512-upluvSRWrlCiExu2UbkuMIPJ9AigyjRFoO7O9eUossIj7rPPq7pcJ0NKk6t2P7KF80tg/UdPX6/pNKOSbs9DEg==
|
||||
|
||||
zwitch@^1.0.0:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue