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

improve global state management & data flow [skip ci]

This commit is contained in:
checktheroads 2020-12-31 21:48:02 -07:00
parent 57f2a2f807
commit 8144ae4c1e
21 changed files with 335 additions and 164 deletions

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { Select } from '~/components';
import { useConfig } from '~/context';
import { useLGState, useLGMethods } from '~/hooks';
import type { TNetwork, TSelectOption } from '~/types';
import type { TQuerySelectField } from './types';
@ -23,6 +24,8 @@ export const QueryLocation = (props: TQuerySelectField) => {
const { networks } = useConfig();
const { errors } = useFormContext();
const { selections } = useLGState();
const { exportState } = useLGMethods();
const options = useMemo(() => buildOptions(networks), [networks.length]);
@ -35,6 +38,7 @@ export const QueryLocation = (props: TQuerySelectField) => {
if (Array.isArray(e)) {
const value = e.map(sel => sel!.value);
onChange({ field: 'query_location', value });
selections.queryLocation.set(e);
}
}
@ -47,6 +51,7 @@ export const QueryLocation = (props: TQuerySelectField) => {
name="query_location"
onChange={handleChange}
closeMenuOnSelect={false}
value={exportState(selections.queryLocation.value)}
isError={typeof errors.query_location !== 'undefined'}
/>
);

View file

@ -33,26 +33,22 @@ const Option = (props: OptionProps<Dict, false>) => {
};
export const QueryTarget = (props: TQueryTarget) => {
const { name, register, onChange, placeholder, resolveTarget } = props;
const { name, register, onChange, placeholder } = props;
const bg = useColorValue('white', 'whiteAlpha.100');
const color = useColorValue('gray.400', 'whiteAlpha.800');
const border = useColorValue('gray.100', 'whiteAlpha.50');
const placeholderColor = useColorValue('gray.600', 'whiteAlpha.700');
const { queryType, queryTarget, fqdnTarget, displayTarget } = useLGState();
const { queryType, queryTarget, displayTarget } = useLGState();
const { queries } = useConfig();
const options = useMemo(() => buildOptions(queries.bgp_community.communities), []);
function handleChange(e: React.ChangeEvent<HTMLInputElement>): void {
function handleInputChange(e: React.ChangeEvent<HTMLInputElement>): void {
displayTarget.set(e.target.value);
onChange({ field: name, value: e.target.value });
if (resolveTarget && displayTarget.value && fqdnPattern.test(displayTarget.value)) {
fqdnTarget.set(displayTarget.value);
}
}
function handleSelectChange(e: TSelectOption | TSelectOption[]): void {
@ -71,8 +67,8 @@ export const QueryTarget = (props: TQueryTarget) => {
name={name}
options={options}
innerRef={register}
onChange={handleSelectChange}
components={{ Option }}
onChange={handleSelectChange}
/>
</If>
<If c={!(queryType.value === 'bgp_community' && queries.bgp_community.mode === 'select')}>
@ -82,11 +78,11 @@ export const QueryTarget = (props: TQueryTarget) => {
color={color}
borderRadius="md"
borderColor={border}
onChange={handleChange}
aria-label={placeholder}
placeholder={placeholder}
value={displayTarget.value}
name="query_target_display"
onChange={handleInputChange}
_placeholder={{ color: placeholderColor }}
/>
</If>

View file

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { Select } from '~/components';
import { useConfig } from '~/context';
import { useLGState, useLGMethods } from '~/hooks';
import type { TQuery, TSelectOption } from '~/types';
import type { TQuerySelectField } from './types';
@ -16,12 +17,17 @@ export const QueryType = (props: TQuerySelectField) => {
const { onChange, label } = props;
const { queries } = useConfig();
const { errors } = useFormContext();
const { selections } = useLGState();
const { exportState } = useLGMethods();
const options = useMemo(() => buildOptions(queries.list), [queries.list.length]);
function handleChange(e: TSelectOption | TSelectOption[]): void {
if (!Array.isArray(e) && e !== null) {
selections.queryType.set(e);
onChange({ field: 'query_type', value: e.value });
} else {
selections.queryType.set(null);
}
}
@ -32,6 +38,7 @@ export const QueryType = (props: TQuerySelectField) => {
options={options}
aria-label={label}
onChange={handleChange}
value={exportState(selections.queryType.value)}
isError={typeof errors.query_type !== 'undefined'}
/>
);

View file

@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { Select } from '~/components';
import { useLGMethods, useLGState } from '~/hooks';
import { TDeviceVrf, TSelectOption } from '~/types';
import type { TQueryVrf } from './types';
@ -10,12 +11,17 @@ function buildOptions(queryVrfs: TDeviceVrf[]): TSelectOption[] {
export const QueryVrf = (props: TQueryVrf) => {
const { vrfs, onChange, label } = props;
const { selections } = useLGState();
const { exportState } = useLGMethods();
const options = useMemo(() => buildOptions(vrfs), [vrfs.length]);
function handleChange(e: TSelectOption | TSelectOption[]): void {
if (!Array.isArray(e) && e !== null) {
selections.queryVrf.set(e);
onChange({ field: 'query_vrf', value: e.value });
} else {
selections.queryVrf.set(null);
}
}
@ -26,6 +32,7 @@ export const QueryVrf = (props: TQueryVrf) => {
options={options}
aria-label={label}
onChange={handleChange}
value={exportState(selections.queryVrf.value)}
/>
);
};

View file

@ -19,11 +19,10 @@ function findAnswer(data: DnsOverHttps.Response | undefined): string {
export const ResolvedTarget = (props: TResolvedTarget) => {
const { setTarget } = props;
const { web } = useConfig();
const { fqdnTarget, isSubmitting, families, formData } = useLGState();
const { displayTarget, isSubmitting, families, queryTarget } = useLGState();
const color = useColorValue('secondary.500', 'secondary.300');
const dnsUrl = web.dns_provider.url;
const query4 = Array.from(families.value).includes(4);
const query6 = Array.from(families.value).includes(6);
@ -34,12 +33,12 @@ export const ResolvedTarget = (props: TResolvedTarget) => {
]);
const { data: data4, isLoading: isLoading4, isError: isError4 } = useDNSQuery(
fqdnTarget.value,
displayTarget.value,
4,
);
const { data: data6, isLoading: isLoading6, isError: isError6 } = useDNSQuery(
fqdnTarget.value,
displayTarget.value,
6,
);
@ -47,7 +46,7 @@ export const ResolvedTarget = (props: TResolvedTarget) => {
setTarget({ field: 'query_target', value });
}
function selectTarget(value: string): void {
formData.set(p => ({ ...p, query_target: value }));
queryTarget.set(value);
isSubmitting.set(true);
}
@ -66,7 +65,7 @@ export const ResolvedTarget = (props: TResolvedTarget) => {
<Text fontSize="sm" textAlign="center">
{messageStart}
<Text as="span" fontSize="sm" fontWeight="bold" color={color}>
{`${fqdnTarget.value}`.toLowerCase()}
{`${displayTarget.value}`.toLowerCase()}
</Text>
{messageEnd}
</Text>

View file

@ -1,5 +1,5 @@
import type { FormControlProps } from '@chakra-ui/react';
import type { FieldError, Control } from 'react-hook-form';
import type { Control } from 'react-hook-form';
import type { TDeviceVrf, TBGPCommunity, OnChangeArgs } from '~/types';
import type { ValidationError } from 'yup';
@ -34,7 +34,6 @@ export interface TCommunitySelect {
export interface TQueryTarget {
name: string;
placeholder: string;
resolveTarget: boolean;
register: Control['register'];
onChange(e: OnChangeArgs): void;
}

View file

@ -2,7 +2,7 @@ import { Flex, Button, VStack } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { If } from '~/components';
import { useConfig, useMobile } from '~/context';
import { useBooleanValue, useLGState } from '~/hooks';
import { useBooleanValue, useLGState, useLGMethods } from '~/hooks';
import { Logo } from './logo';
import { TitleOnly } from './titleOnly';
import { SubtitleOnly } from './subtitleOnly';
@ -98,7 +98,8 @@ export const Title = (props: TTitle) => {
const { web } = useConfig();
const titleMode = web.text.title_mode;
const { isSubmitting, resetForm } = useLGState();
const { isSubmitting } = useLGState();
const { resetForm } = useLGMethods();
const titleHeight = useBooleanValue(isSubmitting.value, undefined, { md: '20vh' });

View file

@ -2,14 +2,15 @@ import { useRef } from 'react';
import { Flex } from '@chakra-ui/react';
import { If, Debugger, Greeting, Footer, Header } from '~/components';
import { useConfig, useColorValue } from '~/context';
import { useLGState } from '~/hooks';
import { useLGState, useLGMethods } from '~/hooks';
import { ResetButton } from './resetButton';
import type { TFrame } from './types';
export const Frame = (props: TFrame) => {
const { developer_mode } = useConfig();
const { isSubmitting, resetForm } = useLGState();
const { isSubmitting } = useLGState();
const { resetForm } = useLGMethods();
const bg = useColorValue('white', 'black');
const color = useColorValue('black', 'white');

View file

@ -1,30 +1,19 @@
import { AnimatePresence } from 'framer-motion';
import { If, HyperglassForm, Results } from '~/components';
import { useLGState } from '~/hooks';
import { all } from '~/util';
import { LookingGlass, Results } from '~/components';
import { useLGMethods } from '~/hooks';
import { Frame } from './frame';
export const Layout: React.FC = () => {
const { isSubmitting, formData } = useLGState();
const { formReady } = useLGMethods();
return (
<Frame>
<If
c={
isSubmitting.value &&
all(
formData.query_location.value,
formData.query_target.value,
formData.query_type.value,
formData.query_vrf.value,
)
}>
{formReady() ? (
<Results />
</If>
<AnimatePresence>
<If c={!isSubmitting.value}>
<HyperglassForm />
</If>
</AnimatePresence>
) : (
<AnimatePresence>
<LookingGlass />
</AnimatePresence>
)}
</Frame>
);
};

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Flex } from '@chakra-ui/react';
import { FormProvider, useForm } from 'react-hook-form';
import { intersectionWith } from 'lodash';
@ -17,17 +17,34 @@ import {
QueryLocation,
} from '~/components';
import { useConfig } from '~/context';
import { useStrf, useGreeting, useDevice, useLGState } from '~/hooks';
import { useStrf, useGreeting, useDevice, useLGState, useLGMethods } from '~/hooks';
import { isQueryType, isQueryContent, isString } from '~/types';
import type { TFormData, TDeviceVrf, OnChangeArgs } from '~/types';
const fqdnPattern = /^(?!:\/\/)([a-zA-Z0-9-]+\.)?[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z-]{2,6}?$/gim;
/**
* Don't set the global flag on this.
* @see https://stackoverflow.com/questions/24084926/javascript-regexp-cant-use-twice
*
* TLDR: the test() will pass the first time, but not the second. In React Strict Mode & in a dev
* environment, this will mean isFqdn will be true the first time, then false the second time,
* submitting the FQDN to hyperglass the second time.
*/
const fqdnPattern = new RegExp(
/^(?!:\/\/)([a-zA-Z0-9-]+\.)?[a-zA-Z0-9-][a-zA-Z0-9-]+\.[a-zA-Z-]{2,6}?$/im,
);
export const HyperglassForm = () => {
const { web, content, messages, queries } = useConfig();
function useIsFqdn(target: string, _type: string) {
return useCallback(
(): boolean => ['bgp_route', 'ping', 'traceroute'].includes(_type) && fqdnPattern.test(target),
[target, _type],
);
}
const [greetingAck, setGreetingAck] = useGreeting();
export const LookingGlass = () => {
const { web, content, messages } = useConfig();
const { ack, greetingReady, isOpen: greetingIsOpen } = useGreeting();
const getDevice = useDevice();
const noQueryType = useStrf(messages.no_input, { field: web.text.query_type });
@ -46,34 +63,55 @@ export const HyperglassForm = () => {
defaultValues: { query_vrf: 'default', query_target: '', query_location: [], query_type: '' },
});
const { handleSubmit, register, unregister, setValue, getValues } = formInstance;
const { handleSubmit, register, setValue } = formInstance;
const {
queryVrf,
families,
formData,
queryType,
availVrfs,
fqdnTarget,
btnLoading,
queryTarget,
isSubmitting,
resolvedOpen,
queryLocation,
displayTarget,
} = useLGState();
function submitHandler(values: TFormData) {
if (!greetingAck && web.greeting.required) {
window.location.reload(false);
setGreetingAck(false);
} else if (fqdnPattern.test(values.query_target)) {
const { resolvedOpen, resetForm } = useLGMethods();
const isFqdnQuery = useIsFqdn(queryTarget.value, queryType.value);
function submitHandler() {
/**
* 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();
}
// Determine if queryTarget is an FQDN.
const isFqdn = isFqdnQuery();
if (greetingReady() && !isFqdn) {
return isSubmitting.set(true);
}
if (greetingReady() && isFqdn) {
btnLoading.set(true);
fqdnTarget.set(values.query_target);
formData.set(values);
resolvedOpen();
return resolvedOpen();
} else {
formData.set(values);
isSubmitting.set(true);
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,
'Is FQDN': isFqdn,
});
console.groupEnd();
}
}
@ -104,38 +142,65 @@ export const HyperglassForm = () => {
queryVrf.set('default');
}
// Determine which address families are available in the intersecting VRFs.
let ipv4 = 0;
let ipv6 = 0;
if (intersecting.length !== 0) {
for (const intersection of intersecting) {
if (intersection.ipv4) {
ipv4++;
}
if (intersection.ipv6) {
ipv6++;
}
for (const intersection of intersecting) {
if (intersection.ipv4) {
// If IPv4 is enabled in this VRF, count it.
ipv4++;
}
if (intersection.ipv6) {
// If IPv6 is enabled in this VRF, count it.
ipv6++;
}
}
if (ipv4 !== 0 && ipv4 === ipv6) {
/**
* If ipv4 & ipv6 are equal, this means every VRF has both IPv4 & IPv6 enabled. In that
* case, signal that both A & AAAA records should be queried if the query is an FQDN.
*/
families.set([4, 6]);
} else if (ipv4 > ipv6) {
/**
* If ipv4 is greater than ipv6, this means that IPv6 is not enabled on all VRFs, i.e. there
* are some VRFs with IPv4 enabled but IPv6 disabled. In that case, only query A records.
*/
families.set([4]);
} else if (ipv4 < ipv6) {
/**
* If ipv6 is greater than ipv4, this means that IPv4 is not enabled on all VRFs, i.e. there
* are some VRFs with IPv6 enabled but IPv4 disabled. In that case, only query AAAA records.
*/
families.set([6]);
} else {
/**
* If both ipv4 and ipv6 are 0, then both ipv4 and ipv6 are disabled, and why does that VRF
* even exist?
*/
families.set([]);
}
}
function handleChange(e: OnChangeArgs): void {
// Signal the field & value to react-hook-form.
setValue(e.field, e.value);
if (e.field === 'query_location' && Array.isArray(e.value)) {
handleLocChange(e.value);
} else if (e.field === 'query_type' && isQueryType(e.value)) {
queryType.set(e.value);
if (queryTarget.value !== '') {
/**
* 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('');
}
} else if (e.field === 'query_vrf' && isString(e.value)) {
queryVrf.set(e.value);
} else if (e.field === 'query_target' && isString(e.value)) {
@ -143,7 +208,14 @@ export const HyperglassForm = () => {
}
}
/**
* Select the correct help content based on the selected VRF & Query Type. Also remove the icon
* if no locations are set.
*/
const vrfContent = useMemo(() => {
if (queryLocation.value.length === 0) {
return null;
}
if (Object.keys(content.vrf).includes(queryVrf.value) && queryType.value !== '') {
return content.vrf[queryVrf.value][queryType.value];
} else {
@ -151,10 +223,6 @@ export const HyperglassForm = () => {
}
}, [queryVrf.value, queryLocation.value, queryType.value]);
const isFqdnQuery = useMemo(() => {
return ['bgp_route', 'ping', 'traceroute'].includes(queryType.value);
}, [queryType.value]);
useEffect(() => {
register({ name: 'query_location', required: true });
register({ name: 'query_target', required: true });
@ -200,7 +268,6 @@ export const HyperglassForm = () => {
name="query_target"
register={register}
onChange={handleChange}
resolveTarget={isFqdnQuery}
placeholder={web.text.query_target}
/>
</FormField>

View file

@ -9,7 +9,7 @@ import {
ModalCloseButton,
} from '@chakra-ui/react';
import { useColorValue } from '~/context';
import { useLGState } from '~/hooks';
import { useLGState, useLGMethods } from '~/hooks';
import { PathButton } from './button';
import { Chart } from './chart';
@ -17,9 +17,9 @@ import type { TPath } from './types';
export const Path = (props: TPath) => {
const { device } = props;
const { getResponse } = useLGState();
const { isOpen, onClose, onOpen } = useDisclosure();
const { displayTarget } = useLGState();
const { getResponse } = useLGMethods();
const { isOpen, onClose, onOpen } = useDisclosure();
const response = getResponse(device);
const output = response?.output as TStructuredResponse;
const bg = useColorValue('whiteFaded.50', 'blackFaded.900');

View file

@ -9,13 +9,8 @@ import { Result } from './individual';
export const Results = () => {
const { queries, vrfs, web } = useConfig();
const { formData } = useLGState();
const {
query_location: queryLocation,
query_target: queryTarget,
query_type: queryType,
query_vrf: queryVrf,
} = formData;
const { queryLocation, queryTarget, queryType, queryVrf } = useLGState();
const getDevice = useDevice();
const targetBg = useToken('colors', 'teal.600');
const queryBg = useToken('colors', 'cyan.500');
@ -91,7 +86,7 @@ export const Results = () => {
maxW={{ base: '100%', lg: '75%', xl: '50%' }}>
<Stack isInline align="center" justify="center" mt={4} flexWrap="wrap">
<AnimatePresence>
{queryLocation && (
{queryLocation.value && (
<>
<motion.div
initial={initialLeft}
@ -150,7 +145,7 @@ export const Results = () => {
maxW={{ base: '100%', md: '75%' }}>
<Accordion allowMultiple allowToggle index={resultsComplete}>
<AnimatePresence>
{queryLocation &&
{queryLocation.value &&
queryLocation.map((loc, i) => {
const device = getDevice(loc.value);
return (

View file

@ -7,5 +7,5 @@ export function isFetchError(error: any): error is Response {
}
export function isLGError(error: any): error is TQueryResponse {
return error !== null && 'output' in error;
return typeof error !== 'undefined' && error !== null && 'output' in error;
}

View file

@ -93,11 +93,11 @@ export const Result = forwardRef<HTMLDivElement, TResult>((props, ref) => {
const errorKeywords = useMemo(() => {
let kw = [] as string[];
if (isLGError(error)) {
kw = error.keywords;
if (isLGError(data)) {
kw = data.keywords;
}
return kw;
}, [isError]);
}, [data]);
let errorMsg;
@ -113,7 +113,7 @@ export const Result = forwardRef<HTMLDivElement, TResult>((props, ref) => {
errorMsg = messages.general;
}
error && console.error(error);
isError && console.error(error);
const errorLevel = useMemo<TErrorLevels>(() => {
const statusMap = {

View file

@ -1,5 +1,5 @@
import type { ButtonProps, FlexProps } from '@chakra-ui/react';
import type { QueryResultBase } from 'react-query';
import type { UseQueryResult } from 'react-query';
import type { TDevice, TQueryTypes } from '~/types';
export interface TResultHeader {
@ -38,5 +38,5 @@ export interface TCopyButton extends ButtonProps {
}
export interface TRequeryButton extends ButtonProps {
requery: QueryResultBase<TQueryResponse>['refetch'];
requery: UseQueryResult<TQueryResponse>['refetch'];
}

View file

@ -14,16 +14,17 @@ import {
PopoverCloseButton,
} from '@chakra-ui/react';
import { FiSearch } from '@meronex/icons/fi';
import { useFormContext } from 'react-hook-form';
import { If, ResolvedTarget } from '~/components';
import { useMobile } from '~/context';
import { useLGState } from '~/hooks';
import { useLGState, useLGMethods } from '~/hooks';
import type { IconButtonProps } from '@chakra-ui/react';
import type { TSubmitButton, TRSubmitButton } from './types';
const SubmitIcon = forwardRef<HTMLButtonElement, Omit<IconButtonProps, 'aria-label'>>(
(props, ref) => {
const { isLoading } = props;
const { isLoading, ...rest } = props;
return (
<IconButton
ref={ref}
@ -35,6 +36,7 @@ const SubmitIcon = forwardRef<HTMLButtonElement, Omit<IconButtonProps, 'aria-lab
colorScheme="primary"
isLoading={isLoading}
aria-label="Submit Query"
{...rest}
/>
);
},
@ -87,12 +89,14 @@ const DSubmitButton = (props: TRSubmitButton) => {
export const SubmitButton = (props: TSubmitButton) => {
const { handleChange } = props;
const { btnLoading, resolvedIsOpen, resolvedClose, resetForm, isSubmitting } = useLGState();
const isMobile = useMobile();
const { resolvedIsOpen, btnLoading } = useLGState();
const { resolvedClose, resetForm } = useLGMethods();
const { reset } = useFormContext();
function handleClose(): void {
btnLoading.set(false);
isSubmitting.set(false);
reset();
resetForm();
resolvedClose();
}

View file

@ -1,23 +0,0 @@
import { createState, useState } from '@hookstate/core';
import type { TGlobalState, TUseGlobalState } from './types';
const defaultFormData = {
query_location: [],
query_target: '',
query_type: '',
query_vrf: '',
} as TGlobalState['formData'];
const globalState = createState<TGlobalState>({
isSubmitting: false,
formData: defaultFormData,
});
export function useGlobalState(): TUseGlobalState {
const state = useState<TGlobalState>(globalState);
function resetForm(): void {
state.formData.set(defaultFormData);
state.isSubmitting.set(false);
}
return { resetForm, ...state };
}

View file

@ -1,2 +1 @@
export * from './HyperglassProvider';
export * from './GlobalState';

View file

@ -15,4 +15,10 @@ interface TGlobalStateFunctions {
resetForm(): void;
}
export type TUseGlobalState = State<TGlobalState> & TGlobalStateFunctions;
// export type TUseGlobalState = State<TGlobalState> & TGlobalStateFunctions;
export interface TUseGlobalState {
isSubmitting: State<TGlobalState['isSubmitting']>;
formData: State<TGlobalState['formData']>;
resetForm(): void;
}

View file

@ -1,39 +1,135 @@
import { useCallback } from 'react';
import { useState, createState } from '@hookstate/core';
import isEqual from 'react-fast-compare';
import { all } from '~/util';
import type { State } from '@hookstate/core';
import type { Families, TDeviceVrf, TQueryTypes, TFormData } from '~/types';
import type { State, PluginStateControl, Plugin } from '@hookstate/core';
import type { Families, TDeviceVrf, TQueryTypes, TSelectOption } from '~/types';
const PluginID = Symbol('Methods');
/**
* Public API
*/
interface MethodsExtension {
getResponse(d: string): TQueryResponse | null;
resolvedClose(): void;
resolvedOpen(): void;
formReady(): boolean;
resetForm(): void;
}
class MethodsInstance {
public resolvedOpen(state: State<TLGState>) {
state.resolvedIsOpen.set(true);
}
public resolvedClose(state: State<TLGState>) {
state.resolvedIsOpen.set(false);
}
public getResponse(state: State<TLGState>, device: string): TQueryResponse | null {
if (device in state.responses) {
return state.responses[device].value;
} else {
return null;
}
}
public formReady(state: State<TLGState>): boolean {
return (
state.isSubmitting.value &&
all(
...[
state.queryVrf.value !== '',
state.queryType.value !== '',
state.queryTarget.value !== '',
state.queryLocation.length !== 0,
],
)
);
}
public resetForm(state: State<TLGState>) {
state.merge({
queryVrf: '',
families: [],
queryType: '',
responses: {},
queryTarget: '',
queryLocation: [],
displayTarget: '',
btnLoading: false,
isSubmitting: false,
resolvedIsOpen: false,
availVrfs: [],
selections: { queryLocation: [], queryType: null, queryVrf: null },
});
}
}
function Methods(): Plugin;
function Methods(inst: State<TLGState>): MethodsExtension;
function Methods(inst?: State<TLGState>): Plugin | MethodsExtension {
if (inst) {
const [instance] = inst.attach(PluginID) 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),
};
}
return {
id: PluginID,
init: () => {
return new MethodsInstance() as {};
},
};
}
interface TSelections {
queryLocation: TSelectOption[] | [];
queryType: TSelectOption | null;
queryVrf: TSelectOption | null;
}
type TLGState = {
queryVrf: string;
families: Families;
queryTarget: string;
btnLoading: boolean;
formData: TFormData;
isSubmitting: boolean;
displayTarget: string;
queryType: TQueryTypes;
queryLocation: string[];
availVrfs: TDeviceVrf[];
resolvedIsOpen: boolean;
fqdnTarget: string | null;
selections: TSelections;
responses: { [d: string]: TQueryResponse };
};
type TLGStateHandlers = {
resolvedOpen(): void;
resolvedClose(): void;
resetForm(): void;
exportState<S extends unknown | null>(s: S): S | null;
getResponse(d: string): TQueryResponse | null;
resolvedClose(): void;
resolvedOpen(): void;
formReady(): boolean;
resetForm(): void;
};
const LGState = createState<TLGState>({
formData: { query_location: [], query_target: '', query_type: '', query_vrf: '' },
selections: { queryLocation: [], queryType: null, queryVrf: null },
resolvedIsOpen: false,
isSubmitting: false,
displayTarget: '',
queryLocation: [],
btnLoading: false,
fqdnTarget: null,
queryTarget: '',
queryType: '',
availVrfs: [],
@ -42,36 +138,31 @@ const LGState = createState<TLGState>({
families: [],
});
export function useLGState(): State<TLGState> & TLGStateHandlers {
const state = useState<TLGState>(LGState);
function resolvedOpen() {
state.resolvedIsOpen.set(true);
}
function resolvedClose() {
state.resolvedIsOpen.set(false);
}
function resetForm() {
state.merge({
queryVrf: '',
families: [],
queryType: '',
queryTarget: '',
fqdnTarget: null,
queryLocation: [],
displayTarget: '',
resolvedIsOpen: false,
btnLoading: false,
formData: { query_location: [], query_target: '', query_type: '', query_vrf: '' },
responses: {},
});
}
function getResponse(device: string): TQueryResponse | null {
if (device in state.responses) {
return state.responses[device].value;
} else {
return null;
}
}
return { resetForm, resolvedOpen, resolvedClose, getResponse, ...state };
export function useLGState(): State<TLGState> {
return useState<TLGState>(LGState);
}
function 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) {
console.error(err.message);
}
return result;
}
export function useLGMethods(): TLGStateHandlers {
const state = useLGState();
state.attach(Methods);
const exporter = useCallback(stateExporter, [isEqual]);
return {
exportState(s) {
return exporter(s);
},
...Methods(state),
};
}

View file

@ -1,4 +1,6 @@
import type { State, InferredStateKeysType } from '@hookstate/core';
import type { TValidQueryTypes, TStringTableData, TQueryResponseString } from './data';
import type { TSelectOption } from './common';
import type { TQueryContent } from './config';
export function isQueryType(q: any): q is TValidQueryTypes {
@ -27,3 +29,29 @@ export function isStringOutput(data: any): data is TQueryResponseString {
export function isQueryContent(c: any): c is TQueryContent {
return typeof c !== 'undefined' && c !== null && 'content' in c;
}
/**
* Determine if an object is a Select option.
*/
export function isSelectOption(a: any): a is NonNullable<TSelectOption> {
return typeof a !== 'undefined' && a !== null && 'label' in a && 'value' in a;
}
/**
* Determine if an object is a HookState Proxy.
*/
export function isState<S>(a: any): a is State<NonNullable<S>> {
let result = false;
if (typeof a !== 'undefined' && a !== null) {
if (
'get' in a &&
typeof a.get === 'function' &&
'set' in a &&
typeof a.set === 'function' &&
'promised' in a
) {
result = true;
}
}
return result;
}