diff --git a/hyperglass/ui/components/form/queryLocation.tsx b/hyperglass/ui/components/form/queryLocation.tsx index 2842f8b..49a8afc 100644 --- a/hyperglass/ui/components/form/queryLocation.tsx +++ b/hyperglass/ui/components/form/queryLocation.tsx @@ -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'} /> ); diff --git a/hyperglass/ui/components/form/queryTarget.tsx b/hyperglass/ui/components/form/queryTarget.tsx index 89393c8..2a6324b 100644 --- a/hyperglass/ui/components/form/queryTarget.tsx +++ b/hyperglass/ui/components/form/queryTarget.tsx @@ -33,26 +33,22 @@ const Option = (props: OptionProps) => { }; 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): void { + function handleInputChange(e: React.ChangeEvent): 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} /> @@ -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 }} /> diff --git a/hyperglass/ui/components/form/queryType.tsx b/hyperglass/ui/components/form/queryType.tsx index ba6cc1b..85b4779 100644 --- a/hyperglass/ui/components/form/queryType.tsx +++ b/hyperglass/ui/components/form/queryType.tsx @@ -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'} /> ); diff --git a/hyperglass/ui/components/form/queryVrf.tsx b/hyperglass/ui/components/form/queryVrf.tsx index 4c6354e..54ad00f 100644 --- a/hyperglass/ui/components/form/queryVrf.tsx +++ b/hyperglass/ui/components/form/queryVrf.tsx @@ -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)} /> ); }; diff --git a/hyperglass/ui/components/form/resolvedTarget.tsx b/hyperglass/ui/components/form/resolvedTarget.tsx index d1a72cd..569b370 100644 --- a/hyperglass/ui/components/form/resolvedTarget.tsx +++ b/hyperglass/ui/components/form/resolvedTarget.tsx @@ -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) => { {messageStart} - {`${fqdnTarget.value}`.toLowerCase()} + {`${displayTarget.value}`.toLowerCase()} {messageEnd} diff --git a/hyperglass/ui/components/form/types.ts b/hyperglass/ui/components/form/types.ts index 8cff3b8..2d0070c 100644 --- a/hyperglass/ui/components/form/types.ts +++ b/hyperglass/ui/components/form/types.ts @@ -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; } diff --git a/hyperglass/ui/components/header/title.tsx b/hyperglass/ui/components/header/title.tsx index 9d84a7b..51ba44d 100644 --- a/hyperglass/ui/components/header/title.tsx +++ b/hyperglass/ui/components/header/title.tsx @@ -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' }); diff --git a/hyperglass/ui/components/layout/frame.tsx b/hyperglass/ui/components/layout/frame.tsx index e08abdf..55a068b 100644 --- a/hyperglass/ui/components/layout/frame.tsx +++ b/hyperglass/ui/components/layout/frame.tsx @@ -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'); diff --git a/hyperglass/ui/components/layout/layout.tsx b/hyperglass/ui/components/layout/layout.tsx index b0d41b4..6810f3f 100644 --- a/hyperglass/ui/components/layout/layout.tsx +++ b/hyperglass/ui/components/layout/layout.tsx @@ -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 ( - + {formReady() ? ( - - - - - - + ) : ( + + + + )} ); }; diff --git a/hyperglass/ui/components/lookingGlass.tsx b/hyperglass/ui/components/lookingGlass.tsx index 9b587a4..8c92a39 100644 --- a/hyperglass/ui/components/lookingGlass.tsx +++ b/hyperglass/ui/components/lookingGlass.tsx @@ -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} /> diff --git a/hyperglass/ui/components/path/path.tsx b/hyperglass/ui/components/path/path.tsx index 6fd69bb..d89ad4e 100644 --- a/hyperglass/ui/components/path/path.tsx +++ b/hyperglass/ui/components/path/path.tsx @@ -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'); diff --git a/hyperglass/ui/components/results/group.tsx b/hyperglass/ui/components/results/group.tsx index c6439fb..db4d3d1 100644 --- a/hyperglass/ui/components/results/group.tsx +++ b/hyperglass/ui/components/results/group.tsx @@ -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%' }}> - {queryLocation && ( + {queryLocation.value && ( <> { maxW={{ base: '100%', md: '75%' }}> - {queryLocation && + {queryLocation.value && queryLocation.map((loc, i) => { const device = getDevice(loc.value); return ( diff --git a/hyperglass/ui/components/results/guards.ts b/hyperglass/ui/components/results/guards.ts index a0458aa..a01a1b8 100644 --- a/hyperglass/ui/components/results/guards.ts +++ b/hyperglass/ui/components/results/guards.ts @@ -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; } diff --git a/hyperglass/ui/components/results/individual.tsx b/hyperglass/ui/components/results/individual.tsx index a672f6a..928684a 100644 --- a/hyperglass/ui/components/results/individual.tsx +++ b/hyperglass/ui/components/results/individual.tsx @@ -93,11 +93,11 @@ export const Result = forwardRef((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((props, ref) => { errorMsg = messages.general; } - error && console.error(error); + isError && console.error(error); const errorLevel = useMemo(() => { const statusMap = { diff --git a/hyperglass/ui/components/results/types.ts b/hyperglass/ui/components/results/types.ts index 74c9c31..5559774 100644 --- a/hyperglass/ui/components/results/types.ts +++ b/hyperglass/ui/components/results/types.ts @@ -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['refetch']; + requery: UseQueryResult['refetch']; } diff --git a/hyperglass/ui/components/submit/submit.tsx b/hyperglass/ui/components/submit/submit.tsx index 93083a3..57fa2a0 100644 --- a/hyperglass/ui/components/submit/submit.tsx +++ b/hyperglass/ui/components/submit/submit.tsx @@ -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>( (props, ref) => { - const { isLoading } = props; + const { isLoading, ...rest } = props; return ( ); }, @@ -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(); } diff --git a/hyperglass/ui/context/GlobalState.ts b/hyperglass/ui/context/GlobalState.ts deleted file mode 100644 index f89acf7..0000000 --- a/hyperglass/ui/context/GlobalState.ts +++ /dev/null @@ -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({ - isSubmitting: false, - formData: defaultFormData, -}); - -export function useGlobalState(): TUseGlobalState { - const state = useState(globalState); - function resetForm(): void { - state.formData.set(defaultFormData); - state.isSubmitting.set(false); - } - return { resetForm, ...state }; -} diff --git a/hyperglass/ui/context/index.ts b/hyperglass/ui/context/index.ts index 1c748e4..757b5cf 100644 --- a/hyperglass/ui/context/index.ts +++ b/hyperglass/ui/context/index.ts @@ -1,2 +1 @@ export * from './HyperglassProvider'; -export * from './GlobalState'; diff --git a/hyperglass/ui/context/types.ts b/hyperglass/ui/context/types.ts index 70f61af..2712f0d 100644 --- a/hyperglass/ui/context/types.ts +++ b/hyperglass/ui/context/types.ts @@ -15,4 +15,10 @@ interface TGlobalStateFunctions { resetForm(): void; } -export type TUseGlobalState = State & TGlobalStateFunctions; +// export type TUseGlobalState = State & TGlobalStateFunctions; + +export interface TUseGlobalState { + isSubmitting: State; + formData: State; + resetForm(): void; +} diff --git a/hyperglass/ui/hooks/useLGState.ts b/hyperglass/ui/hooks/useLGState.ts index 018f456..8a7f6ec 100644 --- a/hyperglass/ui/hooks/useLGState.ts +++ b/hyperglass/ui/hooks/useLGState.ts @@ -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) { + state.resolvedIsOpen.set(true); + } + public resolvedClose(state: State) { + state.resolvedIsOpen.set(false); + } + public getResponse(state: State, device: string): TQueryResponse | null { + if (device in state.responses) { + return state.responses[device].value; + } else { + return null; + } + } + public formReady(state: State): boolean { + return ( + state.isSubmitting.value && + all( + ...[ + state.queryVrf.value !== '', + state.queryType.value !== '', + state.queryTarget.value !== '', + state.queryLocation.length !== 0, + ], + ) + ); + } + public resetForm(state: State) { + 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): MethodsExtension; +function Methods(inst?: State): Plugin | MethodsExtension { + if (inst) { + const [instance] = inst.attach(PluginID) as [ + MethodsInstance | Error, + PluginStateControl, + ]; + + 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: S): S | null; getResponse(d: string): TQueryResponse | null; + resolvedClose(): void; + resolvedOpen(): void; + formReady(): boolean; + resetForm(): void; }; const LGState = createState({ - 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({ families: [], }); -export function useLGState(): State & TLGStateHandlers { - const state = useState(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 { + return useState(LGState); +} + +function stateExporter(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), + }; } diff --git a/hyperglass/ui/types/guards.ts b/hyperglass/ui/types/guards.ts index edf2149..7ff10c4 100644 --- a/hyperglass/ui/types/guards.ts +++ b/hyperglass/ui/types/guards.ts @@ -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 { + return typeof a !== 'undefined' && a !== null && 'label' in a && 'value' in a; +} + +/** + * Determine if an object is a HookState Proxy. + */ +export function isState(a: any): a is State> { + 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; +}