Merge pull request #318 from ylei-tsubame/manage-ups

Add UPS management tab
main
Digimer 2 years ago committed by GitHub
commit 3701517b41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      striker-ui-api/src/lib/accessModule.ts
  2. 2
      striker-ui-api/src/lib/request_handlers/fence/getFenceTemplate.ts
  3. 2
      striker-ui-api/src/lib/request_handlers/host/createHostConnection.ts
  4. 8
      striker-ui-api/src/lib/request_handlers/host/getHostConnection.ts
  5. 37
      striker-ui-api/src/lib/request_handlers/ups/getUPS.ts
  6. 52
      striker-ui-api/src/lib/request_handlers/ups/getUPSTemplate.ts
  7. 2
      striker-ui-api/src/lib/request_handlers/ups/index.ts
  8. 2
      striker-ui-api/src/routes/index.ts
  9. 9
      striker-ui-api/src/routes/ups.ts
  10. 17
      striker-ui-api/src/types/APIUPS.d.ts
  11. 3
      striker-ui-api/src/types/AnvilDataStruct.d.ts
  12. 10
      striker-ui-api/src/types/DatabaseHash.d.ts
  13. 26
      striker-ui-api/src/types/GetAnvilDataFunction.d.ts
  14. 3
      striker-ui-api/src/types/GetAnvilDataOptions.d.ts
  15. 2
      striker-ui/components/ConfirmDialog.tsx
  16. 34
      striker-ui/components/FormDialog.tsx
  17. 13
      striker-ui/components/InputWithRef.tsx
  18. 2
      striker-ui/components/List.tsx
  19. 156
      striker-ui/components/ManageUps/AddUpsInputGroup.tsx
  20. 104
      striker-ui/components/ManageUps/CommonUpsInputGroup.tsx
  21. 45
      striker-ui/components/ManageUps/EditUpsInputGroup.tsx
  22. 301
      striker-ui/components/ManageUps/ManageUpsPanel.tsx
  23. 3
      striker-ui/components/ManageUps/index.tsx
  24. 22
      striker-ui/components/SelectWithLabel.tsx
  25. 4
      striker-ui/components/StrikerConfig/AddPeerDialog.tsx
  26. 19
      striker-ui/hooks/useConfirmDialogProps.ts
  27. 59
      striker-ui/hooks/useFormUtils.ts
  28. 46
      striker-ui/lib/buildMapToMessageSetter.ts
  29. 3
      striker-ui/lib/test_input/buildDomainTestBatch.tsx
  30. 3
      striker-ui/lib/test_input/buildIPAddressTestBatch.tsx
  31. 3
      striker-ui/lib/test_input/buildNumberTestBatch.tsx
  32. 3
      striker-ui/lib/test_input/buildPeacefulStringTestBatch.tsx
  33. 3
      striker-ui/lib/test_input/buildUUIDTestBatch.tsx
  34. 18
      striker-ui/pages/manage-element/index.tsx
  35. 22
      striker-ui/types/APIUps.d.ts
  36. 11
      striker-ui/types/AddUpsInputGroup.d.ts
  37. 11
      striker-ui/types/CommonUpsInputGroup.d.ts
  38. 1
      striker-ui/types/ConfirmDialog.d.ts
  39. 9
      striker-ui/types/EditUpsInputGroup.d.ts
  40. 27
      striker-ui/types/FormUtils.d.ts
  41. 16
      striker-ui/types/MapToMessageSetter.d.ts
  42. 3
      striker-ui/types/SelectWithLabel.d.ts
  43. 3
      striker-ui/types/TestInputFunction.d.ts

@ -134,10 +134,10 @@ const dbWrite = (query: string, options?: SpawnSyncOptions) => {
return execAnvilAccessModule(['--query', query, '--mode', 'write'], options); return execAnvilAccessModule(['--query', query, '--mode', 'write'], options);
}; };
const getAnvilData = ( const getAnvilData = <HashType>(
dataStruct: AnvilDataStruct, dataStruct: AnvilDataStruct,
{ predata, ...spawnSyncOptions }: GetAnvilDataOptions = {}, { predata, ...spawnSyncOptions }: GetAnvilDataOptions = {},
) => ): HashType =>
execAnvilAccessModule( execAnvilAccessModule(
[ [
'--predata', '--predata',

@ -6,7 +6,7 @@ export const getFenceTemplate: RequestHandler = (request, response) => {
let rawFenceData; let rawFenceData;
try { try {
({ fence_data: rawFenceData } = getAnvilData( ({ fence_data: rawFenceData } = getAnvilData<{ fence_data: unknown }>(
{ fence_data: true }, { fence_data: true },
{ predata: [['Striker->get_fence_data']] }, { predata: [['Striker->get_fence_data']] },
)); ));

@ -154,7 +154,7 @@ export const createHostConnection: RequestHandler<
database: { database: {
[localHostUUID]: { port: rawLocalDBPort }, [localHostUUID]: { port: rawLocalDBPort },
}, },
} = getAnvilData({ database: true }) as { database: DatabaseHash }; } = getAnvilData<{ database: AnvilDataDatabaseHash }>({ database: true });
localDBPort = sanitize(rawLocalDBPort, 'number'); localDBPort = sanitize(rawLocalDBPort, 'number');
} catch (subError) { } catch (subError) {

@ -7,7 +7,7 @@ import { stdout } from '../../shell';
const buildHostConnections = ( const buildHostConnections = (
fromHostUUID: string, fromHostUUID: string,
databaseHash: DatabaseHash, databaseHash: AnvilDataDatabaseHash,
{ {
defaultPort = 5432, defaultPort = 5432,
defaultUser = 'admin', defaultUser = 'admin',
@ -42,7 +42,7 @@ export const getHostConnection = buildGetRequestHandler(
(request, buildQueryOptions) => { (request, buildQueryOptions) => {
const { hostUUIDs: rawHostUUIDs } = request.query; const { hostUUIDs: rawHostUUIDs } = request.query;
let rawDatabaseData: DatabaseHash; let rawDatabaseData: AnvilDataDatabaseHash;
const hostUUIDField = 'ip_add.ip_address_host_uuid'; const hostUUIDField = 'ip_add.ip_address_host_uuid';
const localHostUUID: string = getLocalHostUUID(); const localHostUUID: string = getLocalHostUUID();
@ -59,7 +59,9 @@ export const getHostConnection = buildGetRequestHandler(
stdout(`condHostUUIDs=[${condHostUUIDs}]`); stdout(`condHostUUIDs=[${condHostUUIDs}]`);
try { try {
({ database: rawDatabaseData } = getAnvilData({ database: true })); ({ database: rawDatabaseData } = getAnvilData<{ database: AnvilDataDatabaseHash }>(
{ database: true },
));
} catch (subError) { } catch (subError) {
throw new Error(`Failed to get anvil data; CAUSE: ${subError}`); throw new Error(`Failed to get anvil data; CAUSE: ${subError}`);
} }

@ -0,0 +1,37 @@
import { RequestHandler } from 'express';
import buildGetRequestHandler from '../buildGetRequestHandler';
import { buildQueryResultReducer } from '../../buildQueryResultModifier';
export const getUPS: RequestHandler = buildGetRequestHandler(
(request, buildQueryOptions) => {
const query = `
SELECT
ups_uuid,
ups_name,
ups_agent,
ups_ip_address
FROM upses
ORDER BY ups_name ASC;`;
const afterQueryReturn: QueryResultModifierFunction | undefined =
buildQueryResultReducer<{ [upsUUID: string]: UPSOverview }>(
(previous, [upsUUID, upsName, upsAgent, upsIPAddress]) => {
previous[upsUUID] = {
upsAgent,
upsIPAddress,
upsName,
upsUUID,
};
return previous;
},
{},
);
if (buildQueryOptions) {
buildQueryOptions.afterQueryReturn = afterQueryReturn;
}
return query;
},
);

@ -0,0 +1,52 @@
import { RequestHandler } from 'express';
import { getAnvilData } from '../../accessModule';
import { stderr } from '../../shell';
export const getUPSTemplate: RequestHandler = (request, response) => {
let rawUPSData: AnvilDataUPSHash;
try {
({ ups_data: rawUPSData } = getAnvilData<{ ups_data: AnvilDataUPSHash }>(
{ ups_data: true },
{ predata: [['Striker->get_ups_data']] },
));
} catch (subError) {
stderr(`Failed to get ups template; CAUSE: ${subError}`);
response.status(500).send();
return;
}
const upsData: AnvilDataUPSHash = Object.entries(
rawUPSData,
).reduce<UPSTemplate>((previous, [upsTypeId, value]) => {
const { brand, description: rawDescription, ...rest } = value;
const matched = rawDescription.match(
/^(.+)\s+[-]\s+[<][^>]+href=[\\"]+([^\s]+)[\\"]+.+[>]([^<]+)[<]/,
);
const result: UPSTemplate[string] = {
...rest,
brand,
description: rawDescription,
links: {},
};
if (matched) {
const [, description, linkHref, linkLabel] = matched;
result.description = description;
result.links[0] = { linkHref, linkLabel };
}
if (/apc/i.test(brand)) {
previous[upsTypeId] = result;
}
return previous;
}, {});
response.status(200).send(upsData);
};

@ -0,0 +1,2 @@
export * from './getUPS';
export * from './getUPSTemplate';

@ -10,6 +10,7 @@ import jobRouter from './job';
import networkInterfaceRouter from './network-interface'; import networkInterfaceRouter from './network-interface';
import serverRouter from './server'; import serverRouter from './server';
import sshKeyRouter from './ssh-key'; import sshKeyRouter from './ssh-key';
import upsRouter from './ups';
import userRouter from './user'; import userRouter from './user';
const routes: Readonly<Record<string, Router>> = { const routes: Readonly<Record<string, Router>> = {
@ -23,6 +24,7 @@ const routes: Readonly<Record<string, Router>> = {
'network-interface': networkInterfaceRouter, 'network-interface': networkInterfaceRouter,
server: serverRouter, server: serverRouter,
'ssh-key': sshKeyRouter, 'ssh-key': sshKeyRouter,
ups: upsRouter,
user: userRouter, user: userRouter,
}; };

@ -0,0 +1,9 @@
import express from 'express';
import { getUPS, getUPSTemplate } from '../lib/request_handlers/ups';
const router = express.Router();
router.get('/', getUPS).get('/template', getUPSTemplate);
export default router;

@ -0,0 +1,17 @@
type UPSOverview = {
upsAgent: string;
upsIPAddress: string;
upsName: string;
upsUUID: string;
};
type UPSTemplate = {
[upsName: string]: AnvilDataUPSHash[string] & {
links: {
[linkId: string]: {
linkHref: string;
linkLabel: string;
};
};
};
};

@ -1,3 +0,0 @@
interface AnvilDataStruct {
[key: string]: AnvilDataStruct | boolean;
}

@ -1,10 +0,0 @@
type DatabaseHash = {
[hostUUID: string]: {
host: string;
name: string;
password: string;
ping: string;
port: string;
user: string;
};
};

@ -0,0 +1,26 @@
interface AnvilDataStruct {
[key: string]: AnvilDataStruct | boolean;
}
type AnvilDataDatabaseHash = {
[hostUUID: string]: {
host: string;
name: string;
password: string;
ping: string;
port: string;
user: string;
};
};
type AnvilDataUPSHash = {
[upsName: string]: {
agent: string;
brand: string;
description: string;
};
};
type GetAnvilDataOptions = import('child_process').SpawnSyncOptions & {
predata?: Array<[string, ...unknown[]]>;
};

@ -1,3 +0,0 @@
type GetAnvilDataOptions = import('child_process').SpawnSyncOptions & {
predata?: Array<[string, ...unknown[]]>;
};

@ -49,6 +49,7 @@ const ConfirmDialog = forwardRef<
onProceedAppend, onProceedAppend,
onSubmitAppend, onSubmitAppend,
openInitially = false, openInitially = false,
preActionArea,
proceedButtonProps = {}, proceedButtonProps = {},
proceedColour: proceedColourKey = 'blue', proceedColour: proceedColourKey = 'blue',
scrollContent: isScrollContent = false, scrollContent: isScrollContent = false,
@ -234,6 +235,7 @@ const ConfirmDialog = forwardRef<
<Box {...restScrollBoxProps} sx={combinedScrollBoxSx}> <Box {...restScrollBoxProps} sx={combinedScrollBoxSx}>
{contentElement} {contentElement}
</Box> </Box>
{preActionArea}
{actionAreaElement} {actionAreaElement}
</FlexBox> </FlexBox>
</MUIDialog> </MUIDialog>

@ -0,0 +1,34 @@
import { forwardRef, useMemo } from 'react';
import ConfirmDialog from './ConfirmDialog';
const FormDialog = forwardRef<
ConfirmDialogForwardedRefContent,
ConfirmDialogProps
>((props, ref) => {
const { scrollContent: isScrollContent } = props;
const scrollBoxPaddingRight = useMemo(
() => (isScrollContent ? '.5em' : undefined),
[isScrollContent],
);
return (
<ConfirmDialog
dialogProps={{
PaperProps: { sx: { minWidth: { xs: '90%', md: '50em' } } },
}}
formContent
scrollBoxProps={{
paddingRight: scrollBoxPaddingRight,
paddingTop: '.3em',
}}
{...props}
ref={ref}
/>
);
});
FormDialog.displayName = 'FormDialog';
export default FormDialog;

@ -5,7 +5,6 @@ import {
forwardRef, forwardRef,
ReactElement, ReactElement,
useCallback, useCallback,
useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useState, useState,
@ -26,7 +25,7 @@ type InputWithRefOptionalPropsWithoutDefault<
TypeName extends keyof MapToInputType, TypeName extends keyof MapToInputType,
> = { > = {
inputTestBatch?: InputTestBatch; inputTestBatch?: InputTestBatch;
onFirstRender?: (args: { isRequired: boolean }) => void; onFirstRender?: InputFirstRenderFunction;
valueKey?: CreateInputOnChangeHandlerOptions<TypeName>['valueKey']; valueKey?: CreateInputOnChangeHandlerOptions<TypeName>['valueKey'];
}; };
@ -167,11 +166,15 @@ const InputWithRef = forwardRef(
[initOnFocus, inputTestBatch], [initOnFocus, inputTestBatch],
); );
useEffect(() => {
if (isFirstRender) { if (isFirstRender) {
onFirstRender?.call(null, { isRequired }); const isValid =
testInput?.call(null, {
inputs: { [INPUT_TEST_ID]: { value: inputValue } },
isIgnoreOnCallbacks: true,
}) ?? false;
onFirstRender?.call(null, { isValid });
} }
}, [isFirstRender, isRequired, onFirstRender]);
useImperativeHandle( useImperativeHandle(
ref, ref,

@ -176,7 +176,7 @@ const List = forwardRef(
const listEmptyElement = useMemo( const listEmptyElement = useMemo(
() => () =>
typeof listEmpty === 'string' ? ( typeof listEmpty === 'string' ? (
<BodyText>{listEmpty}</BodyText> <BodyText align="center">{listEmpty}</BodyText>
) : ( ) : (
listEmpty listEmpty
), ),

@ -0,0 +1,156 @@
import { ReactElement, ReactNode, useMemo, useState } from 'react';
import { BLACK } from '../../lib/consts/DEFAULT_THEME';
import CommonUpsInputGroup, {
INPUT_ID_UPS_IP,
INPUT_ID_UPS_NAME,
} from './CommonUpsInputGroup';
import FlexBox from '../FlexBox';
import Link from '../Link';
import SelectWithLabel from '../SelectWithLabel';
import Spinner from '../Spinner';
import { BodyText } from '../Text';
import useIsFirstRender from '../../hooks/useIsFirstRender';
const INPUT_ID_UPS_TYPE = 'add-ups-select-ups-type-id';
const INPUT_LABEL_UPS_TYPE = 'UPS type';
const AddUpsInputGroup = <
M extends {
[K in
| typeof INPUT_ID_UPS_IP
| typeof INPUT_ID_UPS_NAME
| typeof INPUT_ID_UPS_TYPE]: string;
},
>({
formUtils,
loading: isExternalLoading,
previous = {},
upsTemplate,
}: AddUpsInputGroupProps<M>): ReactElement => {
const { buildInputFirstRenderFunction, setValidity } = formUtils;
const { upsTypeId: previousUpsTypeId = '' } = previous;
const isFirstRender = useIsFirstRender();
const [inputUpsTypeIdValue, setInputUpsTypeIdValue] =
useState<string>(previousUpsTypeId);
const upsTypeOptions = useMemo<SelectItem[]>(
() =>
upsTemplate
? Object.entries(upsTemplate).map<SelectItem>(
([
upsTypeId,
{
brand,
description,
links: { 0: link },
},
]) => {
let linkElement: ReactNode;
if (link) {
const { linkHref, linkLabel } = link;
linkElement = (
<Link
href={linkHref}
onClick={(event) => {
// Don't trigger the (parent) item selection event.
event.stopPropagation();
}}
sx={{ display: 'inline-flex', color: BLACK }}
target="_blank"
>
{linkLabel}
</Link>
);
}
return {
displayValue: (
<FlexBox spacing={0}>
<BodyText inverted>{brand}</BodyText>
<BodyText inverted>
{description} ({linkElement})
</BodyText>
</FlexBox>
),
value: upsTypeId,
};
},
)
: [],
[upsTemplate],
);
const pickUpsTypeElement = useMemo(
() =>
upsTemplate && (
<SelectWithLabel
formControlProps={{ sx: { marginTop: '.3em' } }}
id={INPUT_ID_UPS_TYPE}
label={INPUT_LABEL_UPS_TYPE}
onChange={({ target: { value: rawNewValue } }) => {
const newValue = String(rawNewValue);
setValidity(INPUT_ID_UPS_TYPE, true);
setInputUpsTypeIdValue(newValue);
}}
required
selectItems={upsTypeOptions}
selectProps={{
onClearIndicatorClick: () => {
setValidity(INPUT_ID_UPS_TYPE, false);
setInputUpsTypeIdValue('');
},
renderValue: (rawValue) => {
const upsTypeId = String(rawValue);
const { brand } = upsTemplate[upsTypeId];
return brand;
},
}}
value={inputUpsTypeIdValue}
/>
),
[upsTemplate, upsTypeOptions, inputUpsTypeIdValue, setValidity],
);
const content = useMemo<ReactElement>(
() =>
isExternalLoading ? (
<Spinner />
) : (
<FlexBox>
{pickUpsTypeElement}
{inputUpsTypeIdValue && (
<CommonUpsInputGroup formUtils={formUtils} previous={previous} />
)}
</FlexBox>
),
[
formUtils,
inputUpsTypeIdValue,
isExternalLoading,
pickUpsTypeElement,
previous,
],
);
if (isFirstRender) {
buildInputFirstRenderFunction(INPUT_ID_UPS_TYPE)({
isValid: Boolean(inputUpsTypeIdValue),
});
}
return content;
};
export { INPUT_ID_UPS_TYPE, INPUT_LABEL_UPS_TYPE };
export default AddUpsInputGroup;

@ -0,0 +1,104 @@
import { ReactElement } from 'react';
import Grid from '../Grid';
import InputWithRef from '../InputWithRef';
import OutlinedInputWithLabel from '../OutlinedInputWithLabel';
import {
buildIPAddressTestBatch,
buildPeacefulStringTestBatch,
} from '../../lib/test_input';
const INPUT_ID_UPS_IP = 'common-ups-input-ip-address';
const INPUT_ID_UPS_NAME = 'common-ups-input-host-name';
const INPUT_LABEL_UPS_IP = 'IP address';
const INPUT_LABEL_UPS_NAME = 'Host name';
const CommonUpsInputGroup = <
M extends {
[K in typeof INPUT_ID_UPS_IP | typeof INPUT_ID_UPS_NAME]: string;
},
>({
formUtils: {
buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction,
msgSetters,
},
previous: { upsIPAddress: previousIpAddress, upsName: previousUpsName } = {},
}: CommonUpsInputGroupProps<M>): ReactElement => (
<Grid
columns={{ xs: 1, sm: 2 }}
layout={{
'common-ups-input-cell-host-name': {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_UPS_NAME}
label={INPUT_LABEL_UPS_NAME}
value={previousUpsName}
/>
}
inputTestBatch={buildPeacefulStringTestBatch(
INPUT_LABEL_UPS_NAME,
() => {
msgSetters[INPUT_ID_UPS_NAME]();
},
{
onFinishBatch:
buildFinishInputTestBatchFunction(INPUT_ID_UPS_NAME),
},
(message) => {
msgSetters[INPUT_ID_UPS_NAME]({
children: message,
});
},
)}
onFirstRender={buildInputFirstRenderFunction(INPUT_ID_UPS_NAME)}
required
/>
),
},
'common-ups-input-cell-ip-address': {
children: (
<InputWithRef
input={
<OutlinedInputWithLabel
id={INPUT_ID_UPS_IP}
label={INPUT_LABEL_UPS_IP}
value={previousIpAddress}
/>
}
inputTestBatch={buildIPAddressTestBatch(
INPUT_LABEL_UPS_IP,
() => {
msgSetters[INPUT_ID_UPS_IP]();
},
{
onFinishBatch:
buildFinishInputTestBatchFunction(INPUT_ID_UPS_IP),
},
(message) => {
msgSetters[INPUT_ID_UPS_IP]({
children: message,
});
},
)}
onFirstRender={buildInputFirstRenderFunction(INPUT_ID_UPS_IP)}
required
/>
),
},
}}
spacing="1em"
/>
);
export {
INPUT_ID_UPS_IP,
INPUT_ID_UPS_NAME,
INPUT_LABEL_UPS_IP,
INPUT_LABEL_UPS_NAME,
};
export default CommonUpsInputGroup;

@ -0,0 +1,45 @@
import { ReactElement, useMemo } from 'react';
import AddUpsInputGroup, { INPUT_ID_UPS_TYPE } from './AddUpsInputGroup';
import { INPUT_ID_UPS_IP, INPUT_ID_UPS_NAME } from './CommonUpsInputGroup';
import Spinner from '../Spinner';
const INPUT_ID_UPS_UUID = 'edit-ups-input-ups-uuid';
const EditUpsInputGroup = <
M extends {
[K in
| typeof INPUT_ID_UPS_IP
| typeof INPUT_ID_UPS_NAME
| typeof INPUT_ID_UPS_TYPE]: string;
},
>({
formUtils,
loading: isExternalLoading,
previous,
upsTemplate,
upsUUID,
}: EditUpsInputGroupProps<M>): ReactElement => {
const content = useMemo<ReactElement>(
() =>
isExternalLoading ? (
<Spinner />
) : (
<>
<AddUpsInputGroup
formUtils={formUtils}
previous={previous}
upsTemplate={upsTemplate}
/>
<input hidden id={INPUT_ID_UPS_UUID} readOnly value={upsUUID} />
</>
),
[formUtils, isExternalLoading, previous, upsTemplate, upsUUID],
);
return content;
};
export { INPUT_ID_UPS_UUID };
export default EditUpsInputGroup;

@ -0,0 +1,301 @@
import {
FC,
FormEventHandler,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import API_BASE_URL from '../../lib/consts/API_BASE_URL';
import AddUpsInputGroup, { INPUT_ID_UPS_TYPE } from './AddUpsInputGroup';
import api from '../../lib/api';
import { INPUT_ID_UPS_IP, INPUT_ID_UPS_NAME } from './CommonUpsInputGroup';
import ConfirmDialog from '../ConfirmDialog';
import EditUpsInputGroup, { INPUT_ID_UPS_UUID } from './EditUpsInputGroup';
import FlexBox from '../FlexBox';
import FormDialog from '../FormDialog';
import handleAPIError from '../../lib/handleAPIError';
import List from '../List';
import MessageGroup, { MessageGroupForwardedRefContent } from '../MessageGroup';
import { Panel, PanelHeader } from '../Panels';
import periodicFetch from '../../lib/fetchers/periodicFetch';
import Spinner from '../Spinner';
import { BodyText, HeaderText, InlineMonoText, MonoText } from '../Text';
import useConfirmDialogProps from '../../hooks/useConfirmDialogProps';
import useFormUtils from '../../hooks/useFormUtils';
import useIsFirstRender from '../../hooks/useIsFirstRender';
import useProtectedState from '../../hooks/useProtectedState';
type UpsFormData = {
upsAgent: string;
upsBrand: string;
upsIPAddress: string;
upsName: string;
upsTypeId: string;
upsUUID: string;
};
const getUpsFormData = (
upsTemplate: APIUpsTemplate,
...[{ target }]: Parameters<FormEventHandler<HTMLDivElement>>
): UpsFormData => {
const { elements } = target as HTMLFormElement;
const { value: upsName } = elements.namedItem(
INPUT_ID_UPS_NAME,
) as HTMLInputElement;
const { value: upsIPAddress } = elements.namedItem(
INPUT_ID_UPS_IP,
) as HTMLInputElement;
const inputUpsTypeId = elements.namedItem(INPUT_ID_UPS_TYPE);
let upsAgent = '';
let upsBrand = '';
let upsTypeId = '';
if (inputUpsTypeId) {
({ value: upsTypeId } = inputUpsTypeId as HTMLInputElement);
({ agent: upsAgent, brand: upsBrand } = upsTemplate[upsTypeId]);
}
const inputUpsUUID = elements.namedItem(INPUT_ID_UPS_UUID);
let upsUUID = '';
if (inputUpsUUID) {
({ value: upsUUID } = inputUpsUUID as HTMLInputElement);
}
return { upsAgent, upsBrand, upsIPAddress, upsName, upsTypeId, upsUUID };
};
const buildConfirmUpsFormData = ({
upsBrand,
upsIPAddress,
upsName,
upsUUID,
}: UpsFormData) => {
const listItems: Record<string, { label: string; value: string }> = {
'ups-brand': { label: 'Brand', value: upsBrand },
'ups-name': { label: 'Host name', value: upsName },
'ups-ip-address': { label: 'IP address', value: upsIPAddress },
};
return (
<List
listItems={listItems}
listItemProps={{ sx: { padding: 0 } }}
renderListItem={(part, { label, value }) => (
<FlexBox fullWidth growFirst key={`confirm-ups-${upsUUID}-${part}`} row>
<BodyText>{label}</BodyText>
<MonoText>{value}</MonoText>
</FlexBox>
)}
/>
);
};
const ManageUpsPanel: FC = () => {
const isFirstRender = useIsFirstRender();
const confirmDialogRef = useRef<ConfirmDialogForwardedRefContent>({});
const formDialogRef = useRef<ConfirmDialogForwardedRefContent>({});
const messageGroupRef = useRef<MessageGroupForwardedRefContent>({});
const [confirmDialogProps, setConfirmDialogProps] = useConfirmDialogProps();
const [formDialogProps, setFormDialogProps] = useConfirmDialogProps();
const [isEditUpses, setIsEditUpses] = useState<boolean>(false);
const [isLoadingUpsTemplate, setIsLoadingUpsTemplate] =
useProtectedState<boolean>(true);
const [upsTemplate, setUpsTemplate] = useProtectedState<
APIUpsTemplate | undefined
>(undefined);
const { data: upsOverviews, isLoading: isUpsOverviewLoading } =
periodicFetch<APIUpsOverview>(`${API_BASE_URL}/ups`, {
refreshInterval: 60000,
});
const formUtils = useFormUtils(
[INPUT_ID_UPS_IP, INPUT_ID_UPS_NAME, INPUT_ID_UPS_TYPE],
messageGroupRef,
);
const { isFormInvalid } = formUtils;
const buildEditUpsFormDialogProps = useCallback<
(args: APIUpsOverview[string]) => ConfirmDialogProps
>(
({ upsAgent, upsIPAddress, upsName, upsUUID }) => {
// Determine the type of existing UPS based on its scan agent.
// TODO: should identity an existing UPS's type in the DB.
const upsTypeId: string =
Object.entries(upsTemplate ?? {}).find(
([, { agent }]) => upsAgent === agent,
)?.[0] ?? '';
return {
actionProceedText: 'Update',
content: (
<EditUpsInputGroup
formUtils={formUtils}
previous={{
upsIPAddress,
upsName,
upsTypeId,
}}
upsTemplate={upsTemplate}
upsUUID={upsUUID}
/>
),
onSubmitAppend: (event) => {
if (!upsTemplate) {
return;
}
const editData = getUpsFormData(upsTemplate, event);
const { upsName: newUpsName } = editData;
setConfirmDialogProps({
actionProceedText: 'Update',
content: buildConfirmUpsFormData(editData),
titleText: (
<HeaderText>
Update{' '}
<InlineMonoText fontSize="inherit">{newUpsName}</InlineMonoText>{' '}
with the following data?
</HeaderText>
),
});
confirmDialogRef.current.setOpen?.call(null, true);
},
titleText: (
<HeaderText>
Update UPS{' '}
<InlineMonoText fontSize="inherit">{upsName}</InlineMonoText>
</HeaderText>
),
};
},
[formUtils, setConfirmDialogProps, upsTemplate],
);
const addUpsFormDialogProps = useMemo<ConfirmDialogProps>(
() => ({
actionProceedText: 'Add',
content: (
<AddUpsInputGroup formUtils={formUtils} upsTemplate={upsTemplate} />
),
onSubmitAppend: (event) => {
if (!upsTemplate) {
return;
}
const addData = getUpsFormData(upsTemplate, event);
const { upsBrand } = addData;
setConfirmDialogProps({
actionProceedText: 'Add',
content: buildConfirmUpsFormData(addData),
titleText: (
<HeaderText>
Add a{' '}
<InlineMonoText fontSize="inherit">{upsBrand}</InlineMonoText> UPS
with the following data?
</HeaderText>
),
});
confirmDialogRef.current.setOpen?.call(null, true);
},
titleText: 'Add a UPS',
}),
[formUtils, setConfirmDialogProps, upsTemplate],
);
const listElement = useMemo(
() => (
<List
allowEdit
allowItemButton={isEditUpses}
edit={isEditUpses}
header
listEmpty="No Ups(es) registered."
listItems={upsOverviews}
onAdd={() => {
setFormDialogProps(addUpsFormDialogProps);
formDialogRef.current.setOpen?.call(null, true);
}}
onEdit={() => {
setIsEditUpses((previous) => !previous);
}}
onItemClick={(value) => {
setFormDialogProps(buildEditUpsFormDialogProps(value));
formDialogRef.current.setOpen?.call(null, true);
}}
renderListItem={(upsUUID, { upsAgent, upsIPAddress, upsName }) => (
<FlexBox fullWidth row>
<BodyText>{upsName}</BodyText>
<BodyText>agent=&quot;{upsAgent}&quot;</BodyText>
<BodyText>ip=&quot;{upsIPAddress}&quot;</BodyText>
</FlexBox>
)}
/>
),
[
addUpsFormDialogProps,
buildEditUpsFormDialogProps,
isEditUpses,
setFormDialogProps,
upsOverviews,
],
);
const panelContent = useMemo(
() =>
isLoadingUpsTemplate || isUpsOverviewLoading ? <Spinner /> : listElement,
[isLoadingUpsTemplate, isUpsOverviewLoading, listElement],
);
if (isFirstRender) {
api
.get<APIUpsTemplate>('/ups/template')
.then(({ data }) => {
setUpsTemplate(data);
})
.catch((error) => {
handleAPIError(error);
})
.finally(() => {
setIsLoadingUpsTemplate(false);
});
}
return (
<>
<Panel>
<PanelHeader>
<HeaderText>Manage UPSes</HeaderText>
</PanelHeader>
{panelContent}
</Panel>
<FormDialog
{...formDialogProps}
ref={formDialogRef}
preActionArea={
<MessageGroup
count={1}
defaultMessageType="warning"
ref={messageGroupRef}
/>
}
proceedButtonProps={{ disabled: isFormInvalid }}
/>
<ConfirmDialog {...confirmDialogProps} ref={confirmDialogRef} />
</>
);
};
export default ManageUpsPanel;

@ -0,0 +1,3 @@
import ManageUpsPanel from './ManageUpsPanel';
export default ManageUpsPanel;

@ -23,7 +23,10 @@ const SelectWithLabel: FC<SelectWithLabelProps> = ({
isReadOnly = false, isReadOnly = false,
messageBoxProps = {}, messageBoxProps = {},
name, name,
onBlur,
onChange, onChange,
onFocus,
required: isRequired,
selectProps: { selectProps: {
multiple: selectMultiple, multiple: selectMultiple,
sx: selectSx, sx: selectSx,
@ -71,15 +74,24 @@ const SelectWithLabel: FC<SelectWithLabelProps> = ({
[createCheckbox, disableItem, hideItem, id], [createCheckbox, disableItem, hideItem, id],
); );
const inputElement = useMemo(() => <OutlinedInput label={label} />, [label]); const selectId = useMemo(() => `${id}-select-element`, [id]);
const inputElement = useMemo(
() => <OutlinedInput id={id} label={label} />,
[id, label],
);
const labelElement = useMemo( const labelElement = useMemo(
() => () =>
label && ( label && (
<OutlinedInputLabel htmlFor={id} {...inputLabelProps}> <OutlinedInputLabel
htmlFor={selectId}
isNotifyRequired={isRequired}
{...inputLabelProps}
>
{label} {label}
</OutlinedInputLabel> </OutlinedInputLabel>
), ),
[id, inputLabelProps, label], [inputLabelProps, isRequired, label, selectId],
); );
const menuItemElements = useMemo( const menuItemElements = useMemo(
() => () =>
@ -96,11 +108,13 @@ const SelectWithLabel: FC<SelectWithLabelProps> = ({
<MUIFormControl fullWidth {...formControlProps}> <MUIFormControl fullWidth {...formControlProps}>
{labelElement} {labelElement}
<Select <Select
id={id} id={selectId}
input={inputElement} input={inputElement}
multiple={selectMultiple} multiple={selectMultiple}
name={name} name={name}
onBlur={onBlur}
onChange={onChange} onChange={onChange}
onFocus={onFocus}
readOnly={isReadOnly} readOnly={isReadOnly}
value={selectValue} value={selectValue}
{...restSelectProps} {...restSelectProps}

@ -61,8 +61,8 @@ const AddPeerDialog = forwardRef<
const buildInputFirstRenderFunction = useCallback( const buildInputFirstRenderFunction = useCallback(
(key: string) => (key: string) =>
({ isRequired }: { isRequired: boolean }) => { ({ isValid }: InputFirstRenderFunctionArgs) => {
setFormValidity(buildObjectStateSetterCallback(key, !isRequired)); setFormValidity(buildObjectStateSetterCallback(key, isValid));
}, },
[], [],
); );

@ -0,0 +1,19 @@
import { Dispatch, SetStateAction, useState } from 'react';
const useConfirmDialogProps = ({
actionProceedText = '',
content = '',
titleText = '',
...restProps
}: Partial<ConfirmDialogProps> = {}): [
ConfirmDialogProps,
Dispatch<SetStateAction<ConfirmDialogProps>>,
] =>
useState<ConfirmDialogProps>({
actionProceedText,
content,
titleText,
...restProps,
});
export default useConfirmDialogProps;

@ -0,0 +1,59 @@
import { MutableRefObject, useCallback, useMemo, useState } from 'react';
import buildMapToMessageSetter from '../lib/buildMapToMessageSetter';
import buildObjectStateSetterCallback from '../lib/buildObjectStateSetterCallback';
import { MessageGroupForwardedRefContent } from '../components/MessageGroup';
const useFormUtils = <
U extends string,
I extends InputIds<U>,
M extends MapToInputId<U, I>,
>(
ids: I,
messageGroupRef: MutableRefObject<MessageGroupForwardedRefContent>,
): FormUtils<M> => {
const [formValidity, setFormValidity] = useState<FormValidity<M>>({});
const setValidity = useCallback((key: keyof M, value: boolean) => {
setFormValidity(
buildObjectStateSetterCallback<FormValidity<M>>(key, value),
);
}, []);
const buildFinishInputTestBatchFunction = useCallback(
(key: keyof M) => (result: boolean) => {
setValidity(key, result);
},
[setValidity],
);
const buildInputFirstRenderFunction = useCallback(
(key: keyof M) =>
({ isValid }: InputFirstRenderFunctionArgs) => {
setValidity(key, isValid);
},
[setValidity],
);
const isFormInvalid = useMemo(
() => Object.values(formValidity).some((isInputValid) => !isInputValid),
[formValidity],
);
const msgSetters = useMemo(
() => buildMapToMessageSetter<U, I, M>(ids, messageGroupRef),
[ids, messageGroupRef],
);
return {
buildFinishInputTestBatchFunction,
buildInputFirstRenderFunction,
formValidity,
isFormInvalid,
msgSetters,
setFormValidity,
setValidity,
};
};
export default useFormUtils;

@ -2,25 +2,49 @@ import { MutableRefObject } from 'react';
import { MessageGroupForwardedRefContent } from '../components/MessageGroup'; import { MessageGroupForwardedRefContent } from '../components/MessageGroup';
type BuildMapToMessageSetterReturnType<T extends MapToInputTestID> = { const buildMessageSetter = <T extends MapToInputTestID>(
[MessageSetterID in keyof T]: MessageSetterFunction; id: string,
};
const buildMapToMessageSetter = <T extends MapToInputTestID>(
mapToID: T,
messageGroupRef: MutableRefObject<MessageGroupForwardedRefContent>, messageGroupRef: MutableRefObject<MessageGroupForwardedRefContent>,
): BuildMapToMessageSetterReturnType<T> => container?: MapToMessageSetter<T>,
Object.entries(mapToID).reduce<BuildMapToMessageSetterReturnType<T>>( key: string = id,
(previous, [key, id]) => { ): MessageSetterFunction => {
const setter: MessageSetterFunction = (message?) => { const setter: MessageSetterFunction = (message?) => {
messageGroupRef.current.setMessage?.call(null, id, message); messageGroupRef.current.setMessage?.call(null, id, message);
}; };
previous[key as keyof T] = setter; if (container) {
container[key as keyof T] = setter;
}
return setter;
};
const buildMapToMessageSetter = <
U extends string,
I extends InputIds<U>,
M extends MapToInputId<U, I>,
>(
ids: I,
messageGroupRef: MutableRefObject<MessageGroupForwardedRefContent>,
): MapToMessageSetter<M> => {
let result: MapToMessageSetter<M> = {} as MapToMessageSetter<M>;
if (ids instanceof Array) {
result = ids.reduce<MapToMessageSetter<M>>((previous, id) => {
buildMessageSetter(id, messageGroupRef, previous);
return previous;
}, result);
} else {
result = Object.entries(ids).reduce<MapToMessageSetter<M>>(
(previous, [key, id]) => {
buildMessageSetter(id, messageGroupRef, previous, key);
return previous; return previous;
}, },
{} as BuildMapToMessageSetterReturnType<T>, result,
); );
}
return result;
};
export default buildMapToMessageSetter; export default buildMapToMessageSetter;

@ -6,10 +6,11 @@ import { InlineMonoText } from '../../components/Text';
const buildDomainTestBatch: BuildInputTestBatchFunction = ( const buildDomainTestBatch: BuildInputTestBatchFunction = (
inputName, inputName,
onSuccess, onSuccess,
{ onFinishBatch, ...defaults } = {}, { isRequired, onFinishBatch, ...defaults } = {},
onDomainTestFailure, onDomainTestFailure,
) => ({ ) => ({
defaults: { ...defaults, onSuccess }, defaults: { ...defaults, onSuccess },
isRequired,
onFinishBatch, onFinishBatch,
tests: [ tests: [
{ {

@ -5,10 +5,11 @@ import testNotBlank from './testNotBlank';
const buildIPAddressTestBatch: BuildInputTestBatchFunction = ( const buildIPAddressTestBatch: BuildInputTestBatchFunction = (
inputName, inputName,
onSuccess, onSuccess,
{ onFinishBatch, ...defaults } = {}, { isRequired, onFinishBatch, ...defaults } = {},
onIPv4TestFailure, onIPv4TestFailure,
) => ({ ) => ({
defaults: { ...defaults, onSuccess }, defaults: { ...defaults, onSuccess },
isRequired,
onFinishBatch, onFinishBatch,
tests: [ tests: [
{ {

@ -4,7 +4,7 @@ import toNumber from '../toNumber';
const buildNumberTestBatch: BuildInputTestBatchFunction = ( const buildNumberTestBatch: BuildInputTestBatchFunction = (
inputName, inputName,
onSuccess, onSuccess,
{ onFinishBatch, ...defaults } = {}, { isRequired, onFinishBatch, ...defaults } = {},
onIntTestFailure?, onIntTestFailure?,
onFloatTestFailure?, onFloatTestFailure?,
onRangeTestFailure?, onRangeTestFailure?,
@ -48,6 +48,7 @@ const buildNumberTestBatch: BuildInputTestBatchFunction = (
return { return {
defaults: { ...defaults, onSuccess }, defaults: { ...defaults, onSuccess },
isRequired,
onFinishBatch, onFinishBatch,
tests, tests,
}; };

@ -6,10 +6,11 @@ import { InlineMonoText } from '../../components/Text';
const buildPeacefulStringTestBatch: BuildInputTestBatchFunction = ( const buildPeacefulStringTestBatch: BuildInputTestBatchFunction = (
inputName, inputName,
onSuccess, onSuccess,
{ onFinishBatch, ...defaults } = {}, { isRequired, onFinishBatch, ...defaults } = {},
onTestPeacefulStringFailureAppend, onTestPeacefulStringFailureAppend,
) => ({ ) => ({
defaults: { ...defaults, onSuccess }, defaults: { ...defaults, onSuccess },
isRequired,
onFinishBatch, onFinishBatch,
tests: [ tests: [
{ {

@ -5,10 +5,11 @@ import testNotBlank from './testNotBlank';
const buildUUIDTestBatch: BuildInputTestBatchFunction = ( const buildUUIDTestBatch: BuildInputTestBatchFunction = (
inputName, inputName,
onSuccess, onSuccess,
{ onFinishBatch, ...defaults } = {}, { isRequired, onFinishBatch, ...defaults } = {},
onUUIDTestFailure, onUUIDTestFailure,
) => ({ ) => ({
defaults: { ...defaults, onSuccess }, defaults: { ...defaults, onSuccess },
isRequired,
onFinishBatch, onFinishBatch,
tests: [ tests: [
{ {

@ -8,6 +8,7 @@ import Grid from '../../components/Grid';
import handleAPIError from '../../lib/handleAPIError'; import handleAPIError from '../../lib/handleAPIError';
import Header from '../../components/Header'; import Header from '../../components/Header';
import ManageFencePanel from '../../components/ManageFence'; import ManageFencePanel from '../../components/ManageFence';
import ManageUpsPanel from '../../components/ManageUps';
import { Panel } from '../../components/Panels'; import { Panel } from '../../components/Panels';
import PrepareHostForm from '../../components/PrepareHostForm'; import PrepareHostForm from '../../components/PrepareHostForm';
import PrepareNetworkForm from '../../components/PrepareNetworkForm'; import PrepareNetworkForm from '../../components/PrepareNetworkForm';
@ -130,6 +131,19 @@ const ManageFenceTabContent: FC = () => (
/> />
); );
const ManageUpsTabContent: FC = () => (
<Grid
columns={STEP_CONTENT_GRID_COLUMNS}
layout={{
'manageups-left-column': {},
'manageups-center-column': {
children: <ManageUpsPanel />,
...STEP_CONTENT_GRID_CENTER_COLUMN,
},
}}
/>
);
const ManageElement: FC = () => { const ManageElement: FC = () => {
const { const {
isReady, isReady,
@ -177,6 +191,7 @@ const ManageElement: FC = () => {
<Tab label="Prepare host" value="prepare-host" /> <Tab label="Prepare host" value="prepare-host" />
<Tab label="Prepare network" value="prepare-network" /> <Tab label="Prepare network" value="prepare-network" />
<Tab label="Manage fence devices" value="manage-fence" /> <Tab label="Manage fence devices" value="manage-fence" />
<Tab label="Manage UPSes" value="manage-ups" />
</Tabs> </Tabs>
</Panel> </Panel>
<TabContent changingTabId={pageTabId} tabId="prepare-host"> <TabContent changingTabId={pageTabId} tabId="prepare-host">
@ -188,6 +203,9 @@ const ManageElement: FC = () => {
<TabContent changingTabId={pageTabId} tabId="manage-fence"> <TabContent changingTabId={pageTabId} tabId="manage-fence">
<ManageFenceTabContent /> <ManageFenceTabContent />
</TabContent> </TabContent>
<TabContent changingTabId={pageTabId} tabId="manage-ups">
<ManageUpsTabContent />
</TabContent>
</> </>
); );
}; };

@ -0,0 +1,22 @@
type APIUpsTemplate = {
[upsTypeId: string]: {
agent: string;
brand: string;
description: string;
links: {
[linkId: string]: {
linkHref: string;
linkLabel: string;
};
};
};
};
type APIUpsOverview = {
[upsUUID: string]: {
upsAgent: string;
upsIPAddress: string;
upsName: string;
upsUUID: string;
};
};

@ -0,0 +1,11 @@
type AddUpsInputGroupOptionalProps = {
loading?: boolean;
previous?: CommonUpsInputGroupOptionalProps['previous'] & {
upsTypeId?: string;
};
upsTemplate?: APIUpsTemplate;
};
type AddUpsInputGroupProps<M extends MapToInputTestID> =
AddUpsInputGroupOptionalProps &
Pick<CommonUpsInputGroupProps<M>, 'formUtils'>;

@ -0,0 +1,11 @@
type CommonUpsInputGroupOptionalProps = {
previous?: {
upsIPAddress?: string;
upsName?: string;
};
};
type CommonUpsInputGroupProps<M extends MapToInputTestID> =
CommonUpsInputGroupOptionalProps & {
formUtils: FormUtils<M>;
};

@ -10,6 +10,7 @@ type ConfirmDialogOptionalProps = {
onCancelAppend?: ContainedButtonProps['onClick']; onCancelAppend?: ContainedButtonProps['onClick'];
onSubmitAppend?: import('react').FormEventHandler<HTMLDivElement>; onSubmitAppend?: import('react').FormEventHandler<HTMLDivElement>;
openInitially?: boolean; openInitially?: boolean;
preActionArea?: import('react').ReactNode;
proceedButtonProps?: ContainedButtonProps; proceedButtonProps?: ContainedButtonProps;
proceedColour?: 'blue' | 'red'; proceedColour?: 'blue' | 'red';
scrollContent?: boolean; scrollContent?: boolean;

@ -0,0 +1,9 @@
type EditUpsInputGroupOptionalProps = {
loading?: boolean;
};
type EditUpsInputGroupProps<M extends MapToInputTestID> =
EditUpsInputGroupOptionalProps &
Pick<AddUpsInputGroupProps<M>, 'formUtils' | 'previous' | 'upsTemplate'> & {
upsUUID: string;
};

@ -0,0 +1,27 @@
type FormValidity<T> = {
[K in keyof T]?: boolean;
};
type InputTestBatchFinishCallbackBuilder<M extends MapToInputTestID> = (
key: keyof M,
) => InputTestBatchFinishCallback;
type InputFirstRenderFunctionArgs = { isValid: boolean };
type InputFirstRenderFunction = (args: InputFirstRenderFunctionArgs) => void;
type InputFirstRenderFunctionBuilder<M extends MapToInputTestID> = (
key: keyof M,
) => InputFirstRenderFunction;
type FormUtils<M extends MapToInputTestID> = {
buildFinishInputTestBatchFunction: InputTestBatchFinishCallbackBuilder<M>;
buildInputFirstRenderFunction: InputFirstRenderFunctionBuilder<M>;
formValidity: FormValidity<M>;
isFormInvalid: boolean;
msgSetters: MapToMessageSetter<M>;
setFormValidity: import('react').Dispatch<
import('react').SetStateAction<FormValidity<M>>
>;
setValidity: (key: keyof M, value: boolean) => void;
};

@ -0,0 +1,16 @@
type MapToMessageSetter<T extends MapToInputTestID> = {
[MessageSetterID in keyof T]: MessageSetterFunction;
};
type InputIds<T> = ReadonlyArray<T> | MapToInputTestID;
/**
* Given either:
* 1. an array of input identifiers, or
* 2. a key-value object of input indentifiers,
* transform it into a key-value object of identifiers.
*/
type MapToInputId<
U extends string,
I extends InputIds<U>,
> = I extends ReadonlyArray<U> ? { [K in I[number]]: K } : I;

@ -20,11 +20,12 @@ type SelectWithLabelOptionalProps = {
>; >;
label?: string; label?: string;
messageBoxProps?: Partial<import('../components/MessageBox').MessageBoxProps>; messageBoxProps?: Partial<import('../components/MessageBox').MessageBoxProps>;
required?: boolean;
selectProps?: Partial<SelectProps>; selectProps?: Partial<SelectProps>;
}; };
type SelectWithLabelProps = SelectWithLabelOptionalProps & type SelectWithLabelProps = SelectWithLabelOptionalProps &
Pick<SelectProps, 'name' | 'onChange' | 'value'> & { Pick<SelectProps, 'name' | 'onBlur' | 'onChange' | 'onFocus' | 'value'> & {
id: string; id: string;
selectItems: Array<SelectItem | string>; selectItems: Array<SelectItem | string>;
}; };

@ -65,7 +65,8 @@ type InputTestBatch = {
type BuildInputTestBatchFunction = ( type BuildInputTestBatchFunction = (
inputName: string, inputName: string,
onSuccess: InputTestSuccessCallback, onSuccess: InputTestSuccessCallback,
options?: InputTestBatch['defaults'] & Pick<InputTestBatch, 'onFinishBatch'>, options?: InputTestBatch['defaults'] &
Pick<InputTestBatch, 'isRequired' | 'onFinishBatch'>,
...onFailureAppends: InputTestFailureAppendCallback[] ...onFailureAppends: InputTestFailureAppendCallback[]
) => InputTestBatch; ) => InputTestBatch;

Loading…
Cancel
Save