Merge pull request #317 from ylei-tsubame/manage-fence-device

Add fence device management tab
main
Digimer 2 years ago committed by GitHub
commit 941221fa41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 56
      striker-ui-api/src/lib/request_handlers/fence/getFence.ts
  2. 22
      striker-ui-api/src/lib/request_handlers/fence/getFenceTemplate.ts
  3. 2
      striker-ui-api/src/lib/request_handlers/fence/index.ts
  4. 9
      striker-ui-api/src/routes/fence.ts
  5. 2
      striker-ui-api/src/routes/index.ts
  6. 10
      striker-ui-api/src/types/APIFence.d.ts
  7. 125
      striker-ui/components/ConfirmDialog.tsx
  8. 16
      striker-ui/components/ContainedButton.tsx
  9. 17
      striker-ui/components/FlexBox.tsx
  10. 102
      striker-ui/components/IconButton/IconButton.tsx
  11. 69
      striker-ui/components/InputWithRef.tsx
  12. 54
      striker-ui/components/List.tsx
  13. 108
      striker-ui/components/ManageFence/AddFenceInputGroup.tsx
  14. 246
      striker-ui/components/ManageFence/CommonFenceInputGroup.tsx
  15. 37
      striker-ui/components/ManageFence/EditFenceInputGroup.tsx
  16. 356
      striker-ui/components/ManageFence/ManageFencePanel.tsx
  17. 3
      striker-ui/components/ManageFence/index.tsx
  18. 15
      striker-ui/components/OutlinedInputWithLabel.tsx
  19. 18
      striker-ui/components/OutlinedLabeledInputWithSelect.tsx
  20. 37
      striker-ui/components/Panels/ExpandablePanel.tsx
  21. 22
      striker-ui/components/Panels/InnerPanel.tsx
  22. 72
      striker-ui/components/Select.tsx
  23. 145
      striker-ui/components/SelectWithLabel.tsx
  24. 5
      striker-ui/components/StrikerConfig/ConfigPeersForm.tsx
  25. 5
      striker-ui/components/StrikerConfig/ManageChangedSSHKeysForm.tsx
  26. 5
      striker-ui/components/StrikerConfig/ManageUsersForm.tsx
  27. 56
      striker-ui/components/SwitchWithLabel.tsx
  28. 18
      striker-ui/components/TabContent.tsx
  29. 20
      striker-ui/components/Text/BodyText.tsx
  30. 9
      striker-ui/components/Text/InlineMonoText.tsx
  31. 6
      striker-ui/components/Text/MonoText.tsx
  32. 113
      striker-ui/components/Text/SensitiveText.tsx
  33. 6
      striker-ui/components/Text/SmallText.tsx
  34. 10
      striker-ui/components/Text/index.tsx
  35. 1
      striker-ui/lib/consts/DEFAULT_THEME.ts
  36. 7
      striker-ui/lib/createInputOnChangeHandler.ts
  37. 26
      striker-ui/pages/manage-element/index.tsx
  38. 43
      striker-ui/types/APIFence.d.ts
  39. 12
      striker-ui/types/AddFenceInputGroup.d.ts
  40. 28
      striker-ui/types/CommonFenceInputGroup.d.ts
  41. 9
      striker-ui/types/ConfirmDialog.d.ts
  42. 6
      striker-ui/types/CreateInputOnChangeHandlerFunction.d.ts
  43. 12
      striker-ui/types/EditFenceInputGroup.d.ts
  44. 10
      striker-ui/types/ExpandablePanel.d.ts
  45. 16
      striker-ui/types/IconButton.d.ts
  46. 1
      striker-ui/types/InnerPanel.d.ts
  47. 23
      striker-ui/types/List.d.ts
  48. 5
      striker-ui/types/Select.d.ts
  49. 23
      striker-ui/types/SelectWithLabel.d.ts
  50. 8
      striker-ui/types/SensitiveText.d.ts
  51. 12
      striker-ui/types/SwitchWithLabel.d.ts
  52. 7
      striker-ui/types/TabContent.d.ts

@ -0,0 +1,56 @@
import { RequestHandler } from 'express';
import buildGetRequestHandler from '../buildGetRequestHandler';
import { buildQueryResultReducer } from '../../buildQueryResultModifier';
import { stdout } from '../../shell';
export const getFence: RequestHandler = buildGetRequestHandler(
(request, buildQueryOptions) => {
const query = `
SELECT
fence_uuid,
fence_name,
fence_agent,
fence_arguments
FROM fences
ORDER BY fence_name ASC;`;
const afterQueryReturn: QueryResultModifierFunction | undefined =
buildQueryResultReducer<{ [fenceUUID: string]: FenceOverview }>(
(previous, [fenceUUID, fenceName, fenceAgent, fenceArgumentString]) => {
const fenceParameters = fenceArgumentString
.split(/\s+/)
.reduce<FenceParameters>((previous, parameterPair) => {
const [parameterId, parameterValue] = parameterPair.split(/=/);
previous[parameterId] = parameterValue.replace(/['"]/g, '');
return previous;
}, {});
stdout(
`${fenceAgent}: ${fenceName} (${fenceUUID})\n${JSON.stringify(
fenceParameters,
null,
2,
)}`,
);
previous[fenceUUID] = {
fenceAgent,
fenceParameters,
fenceName,
fenceUUID,
};
return previous;
},
{},
);
if (buildQueryOptions) {
buildQueryOptions.afterQueryReturn = afterQueryReturn;
}
return query;
},
);

@ -0,0 +1,22 @@
import { RequestHandler } from 'express';
import { getAnvilData } from '../../accessModule';
import { stderr } from '../../shell';
export const getFenceTemplate: RequestHandler = (request, response) => {
let rawFenceData;
try {
({ fence_data: rawFenceData } = getAnvilData(
{ fence_data: true },
{ predata: [['Striker->get_fence_data']] },
));
} catch (subError) {
stderr(`Failed to get fence device template; CAUSE: ${subError}`);
response.status(500).send();
return;
}
response.status(200).send(rawFenceData);
};

@ -0,0 +1,2 @@
export * from './getFence';
export * from './getFenceTemplate';

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

@ -3,6 +3,7 @@ import { Router } from 'express';
import anvilRouter from './anvil'; import anvilRouter from './anvil';
import commandRouter from './command'; import commandRouter from './command';
import echoRouter from './echo'; import echoRouter from './echo';
import fenceRouter from './fence';
import fileRouter from './file'; import fileRouter from './file';
import hostRouter from './host'; import hostRouter from './host';
import jobRouter from './job'; import jobRouter from './job';
@ -15,6 +16,7 @@ const routes: Readonly<Record<string, Router>> = {
anvil: anvilRouter, anvil: anvilRouter,
command: commandRouter, command: commandRouter,
echo: echoRouter, echo: echoRouter,
fence: fenceRouter,
file: fileRouter, file: fileRouter,
host: hostRouter, host: hostRouter,
job: jobRouter, job: jobRouter,

@ -0,0 +1,10 @@
type FenceParameters = {
[parameterId: string]: string;
};
type FenceOverview = {
fenceAgent: string;
fenceParameters: FenceParameters;
fenceName: string;
fenceUUID: string;
};

@ -1,5 +1,14 @@
import { Box, Dialog } from '@mui/material'; import { Box, Dialog as MUIDialog, SxProps, Theme } from '@mui/material';
import { forwardRef, useImperativeHandle, useMemo, useState } from 'react'; import {
ButtonHTMLAttributes,
ElementType,
FormEventHandler,
forwardRef,
MouseEventHandler,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import { BLUE, RED, TEXT } from '../lib/consts/DEFAULT_THEME'; import { BLUE, RED, TEXT } from '../lib/consts/DEFAULT_THEME';
@ -25,6 +34,7 @@ const ConfirmDialog = forwardRef<
{ {
actionCancelText = 'Cancel', actionCancelText = 'Cancel',
actionProceedText, actionProceedText,
contentContainerProps = {},
closeOnProceed: isCloseOnProceed = false, closeOnProceed: isCloseOnProceed = false,
content, content,
dialogProps: { dialogProps: {
@ -32,13 +42,17 @@ const ConfirmDialog = forwardRef<
PaperProps: paperProps = {}, PaperProps: paperProps = {},
...restDialogProps ...restDialogProps
} = {}, } = {},
formContent: isFormContent,
loadingAction: isLoadingAction = false, loadingAction: isLoadingAction = false,
onActionAppend, onActionAppend,
onCancelAppend, onCancelAppend,
onProceedAppend, onProceedAppend,
onSubmitAppend,
openInitially = false, openInitially = false,
proceedButtonProps = {}, proceedButtonProps = {},
proceedColour: proceedColourKey = 'blue', proceedColour: proceedColourKey = 'blue',
scrollContent: isScrollContent = false,
scrollBoxProps: { sx: scrollBoxSx, ...restScrollBoxProps } = {},
titleText, titleText,
}, },
ref, ref,
@ -59,6 +73,54 @@ const ConfirmDialog = forwardRef<
() => MAP_TO_COLOUR[proceedColourKey], () => MAP_TO_COLOUR[proceedColourKey],
[proceedColourKey], [proceedColourKey],
); );
const {
contentContainerComponent,
contentContainerSubmitEventHandler,
proceedButtonClickEventHandler,
proceedButtonType,
} = useMemo(() => {
let ccComponent: ElementType | undefined;
let ccSubmitEventHandler: FormEventHandler<HTMLDivElement> | undefined;
let pbClickEventHandler:
| MouseEventHandler<HTMLButtonElement>
| undefined = (...args) => {
if (isCloseOnProceed) {
setIsOpen(false);
}
onActionAppend?.call(null, ...args);
onProceedAppend?.call(null, ...args);
};
let pbType: ButtonHTMLAttributes<HTMLButtonElement>['type'] | undefined;
if (isFormContent) {
ccComponent = 'form';
ccSubmitEventHandler = (event, ...restArgs) => {
event.preventDefault();
if (isCloseOnProceed) {
setIsOpen(false);
}
onSubmitAppend?.call(null, event, ...restArgs);
};
pbClickEventHandler = undefined;
pbType = 'submit';
}
return {
contentContainerComponent: ccComponent,
contentContainerSubmitEventHandler: ccSubmitEventHandler,
proceedButtonClickEventHandler: pbClickEventHandler,
proceedButtonType: pbType,
};
}, [
isCloseOnProceed,
isFormContent,
onActionAppend,
onProceedAppend,
onSubmitAppend,
]);
const cancelButtonElement = useMemo( const cancelButtonElement = useMemo(
() => ( () => (
@ -78,14 +140,8 @@ const ConfirmDialog = forwardRef<
const proceedButtonElement = useMemo( const proceedButtonElement = useMemo(
() => ( () => (
<ContainedButton <ContainedButton
onClick={(...args) => { onClick={proceedButtonClickEventHandler}
if (isCloseOnProceed) { type={proceedButtonType}
setIsOpen(false);
}
onActionAppend?.call(null, ...args);
onProceedAppend?.call(null, ...args);
}}
{...restProceedButtonProps} {...restProceedButtonProps}
sx={{ sx={{
backgroundColor: proceedColour, backgroundColor: proceedColour,
@ -101,14 +157,14 @@ const ConfirmDialog = forwardRef<
), ),
[ [
actionProceedText, actionProceedText,
isCloseOnProceed, proceedButtonClickEventHandler,
onActionAppend,
onProceedAppend,
proceedButtonSx, proceedButtonSx,
proceedButtonType,
proceedColour, proceedColour,
restProceedButtonProps, restProceedButtonProps,
], ],
); );
const actionAreaElement = useMemo( const actionAreaElement = useMemo(
() => () =>
isLoadingAction ? ( isLoadingAction ? (
@ -125,6 +181,31 @@ const ConfirmDialog = forwardRef<
), ),
[cancelButtonElement, isLoadingAction, proceedButtonElement], [cancelButtonElement, isLoadingAction, proceedButtonElement],
); );
const contentElement = useMemo(
() =>
typeof content === 'string' ? <BodyText text={content} /> : content,
[content],
);
const headerElement = useMemo(
() =>
typeof titleText === 'string' ? (
<HeaderText>{titleText}</HeaderText>
) : (
titleText
),
[titleText],
);
const combinedScrollBoxSx = useMemo<SxProps<Theme> | undefined>(
() =>
isScrollContent
? {
maxHeight: '60vh',
overflowY: 'scroll',
...scrollBoxSx,
}
: undefined,
[isScrollContent, scrollBoxSx],
);
useImperativeHandle( useImperativeHandle(
ref, ref,
@ -135,7 +216,7 @@ const ConfirmDialog = forwardRef<
); );
return ( return (
<Dialog <MUIDialog
open={open} open={open}
PaperComponent={Panel} PaperComponent={Panel}
PaperProps={{ PaperProps={{
@ -144,14 +225,18 @@ const ConfirmDialog = forwardRef<
}} }}
{...restDialogProps} {...restDialogProps}
> >
<PanelHeader> <PanelHeader>{headerElement}</PanelHeader>
<HeaderText text={titleText} /> <FlexBox
</PanelHeader> component={contentContainerComponent}
<Box sx={{ marginBottom: '1em' }}> onSubmit={contentContainerSubmitEventHandler}
{typeof content === 'string' ? <BodyText text={content} /> : content} {...contentContainerProps}
>
<Box {...restScrollBoxProps} sx={combinedScrollBoxSx}>
{contentElement}
</Box> </Box>
{actionAreaElement} {actionAreaElement}
</Dialog> </FlexBox>
</MUIDialog>
); );
}, },
); );

@ -1,17 +1,17 @@
import { Button as MUIButton, SxProps, Theme } from '@mui/material'; import { Button as MUIButton, SxProps, Theme } from '@mui/material';
import { FC, useMemo } from 'react'; import { FC, useMemo } from 'react';
import { BLACK, GREY, TEXT } from '../lib/consts/DEFAULT_THEME'; import { BLACK, GREY } from '../lib/consts/DEFAULT_THEME';
const ContainedButton: FC<ContainedButtonProps> = ({ sx, ...restProps }) => { const ContainedButton: FC<ContainedButtonProps> = ({ sx, ...restProps }) => {
const combinedSx = useMemo<SxProps<Theme>>( const combinedSx = useMemo<SxProps<Theme>>(
() => ({ () => ({
backgroundColor: TEXT, backgroundColor: GREY,
color: BLACK, color: BLACK,
textTransform: 'none', textTransform: 'none',
'&:hover': { '&:hover': {
backgroundColor: GREY, backgroundColor: `${GREY}F0`,
}, },
...sx, ...sx,
@ -19,15 +19,7 @@ const ContainedButton: FC<ContainedButtonProps> = ({ sx, ...restProps }) => {
[sx], [sx],
); );
return ( return <MUIButton variant="contained" {...restProps} sx={combinedSx} />;
<MUIButton
{...{
variant: 'contained',
...restProps,
sx: combinedSx,
}}
/>
);
}; };
export default ContainedButton; export default ContainedButton;

@ -6,6 +6,8 @@ type FlexBoxDirection = 'column' | 'row';
type FlexBoxSpacing = number | string; type FlexBoxSpacing = number | string;
type FlexBoxOptionalPropsWithDefault = { type FlexBoxOptionalPropsWithDefault = {
fullWidth?: boolean;
growFirst?: boolean;
row?: boolean; row?: boolean;
spacing?: FlexBoxSpacing; spacing?: FlexBoxSpacing;
xs?: FlexBoxDirection; xs?: FlexBoxDirection;
@ -28,6 +30,8 @@ type FlexBoxProps = MUIBoxProps & FlexBoxOptionalProps;
const FLEX_BOX_DEFAULT_PROPS: Required<FlexBoxOptionalPropsWithDefault> & const FLEX_BOX_DEFAULT_PROPS: Required<FlexBoxOptionalPropsWithDefault> &
FlexBoxOptionalPropsWithoutDefault = { FlexBoxOptionalPropsWithoutDefault = {
columnSpacing: undefined, columnSpacing: undefined,
fullWidth: false,
growFirst: false,
row: false, row: false,
rowSpacing: undefined, rowSpacing: undefined,
lg: undefined, lg: undefined,
@ -39,6 +43,8 @@ const FLEX_BOX_DEFAULT_PROPS: Required<FlexBoxOptionalPropsWithDefault> &
}; };
const FlexBox: FC<FlexBoxProps> = ({ const FlexBox: FC<FlexBoxProps> = ({
fullWidth,
growFirst,
lg: dLg = FLEX_BOX_DEFAULT_PROPS.lg, lg: dLg = FLEX_BOX_DEFAULT_PROPS.lg,
md: dMd = FLEX_BOX_DEFAULT_PROPS.md, md: dMd = FLEX_BOX_DEFAULT_PROPS.md,
row: isRow, row: isRow,
@ -50,7 +56,6 @@ const FlexBox: FC<FlexBoxProps> = ({
// Input props that depend on other input props. // Input props that depend on other input props.
columnSpacing = spacing, columnSpacing = spacing,
rowSpacing = spacing, rowSpacing = spacing,
...muiBoxRestProps ...muiBoxRestProps
}) => { }) => {
const xs = useMemo(() => (isRow ? 'row' : dXs), [dXs, isRow]); const xs = useMemo(() => (isRow ? 'row' : dXs), [dXs, isRow]);
@ -81,6 +86,11 @@ const FlexBox: FC<FlexBoxProps> = ({
}), }),
[columnSpacing, rowSpacing], [columnSpacing, rowSpacing],
); );
const firstChildFlexGrow = useMemo(
() => (growFirst ? 1 : undefined),
[growFirst],
);
const width = useMemo(() => (fullWidth ? '100%' : undefined), [fullWidth]);
return ( return (
<MUIBox <MUIBox
@ -96,6 +106,11 @@ const FlexBox: FC<FlexBoxProps> = ({
}, },
display: 'flex', display: 'flex',
flexDirection: { xs, sm, md, lg, xl }, flexDirection: { xs, sm, md, lg, xl },
width,
'& > :first-child': {
flexGrow: firstChildFlexGrow,
},
'& > :not(:first-child)': { '& > :not(:first-child)': {
marginLeft: { marginLeft: {

@ -1,47 +1,111 @@
import { FC } from 'react'; import {
Done as MUIDoneIcon,
Edit as MUIEditIcon,
Visibility as MUIVisibilityIcon,
VisibilityOff as MUIVisibilityOffIcon,
} from '@mui/icons-material';
import { import {
IconButton as MUIIconButton, IconButton as MUIIconButton,
IconButtonProps as MUIIconButtonProps, IconButtonProps as MUIIconButtonProps,
inputClasses as muiInputClasses, inputClasses as muiInputClasses,
styled,
} from '@mui/material'; } from '@mui/material';
import { createElement, FC, ReactNode, useMemo } from 'react';
import { import {
BLACK, BLACK,
BORDER_RADIUS, BORDER_RADIUS,
DISABLED, DISABLED,
GREY, GREY,
TEXT,
} from '../../lib/consts/DEFAULT_THEME'; } from '../../lib/consts/DEFAULT_THEME';
export type IconButtonProps = MUIIconButtonProps; type IconButtonProps = IconButtonOptionalProps & MUIIconButtonProps;
const IconButton: FC<IconButtonProps> = ({ const ContainedIconButton = styled(MUIIconButton)({
children,
sx,
...iconButtonRestProps
}) => (
<MUIIconButton
{...{
...iconButtonRestProps,
sx: {
borderRadius: BORDER_RADIUS, borderRadius: BORDER_RADIUS,
backgroundColor: GREY, backgroundColor: GREY,
color: BLACK, color: BLACK,
'&:hover': { '&:hover': {
backgroundColor: TEXT, backgroundColor: `${GREY}F0`,
}, },
[`&.${muiInputClasses.disabled}`]: { [`&.${muiInputClasses.disabled}`]: {
backgroundColor: DISABLED, backgroundColor: DISABLED,
}, },
});
...sx, const NormalIconButton = styled(MUIIconButton)({
}, color: GREY,
}} });
>
{children} const MAP_TO_VISIBILITY_ICON: IconButtonMapToStateIcon = {
</MUIIconButton> false: MUIVisibilityIcon,
true: MUIVisibilityOffIcon,
};
const MAP_TO_EDIT_ICON: IconButtonMapToStateIcon = {
false: MUIEditIcon,
true: MUIDoneIcon,
};
const MAP_TO_MAP_PRESET: Record<
IconButtonPresetMapToStateIcon,
IconButtonMapToStateIcon
> = {
edit: MAP_TO_EDIT_ICON,
visibility: MAP_TO_VISIBILITY_ICON,
};
const MAP_TO_VARIANT: Record<IconButtonVariant, CreatableComponent> = {
contained: ContainedIconButton,
normal: NormalIconButton,
};
const IconButton: FC<IconButtonProps> = ({
children,
defaultIcon,
iconProps,
mapPreset,
mapToIcon: externalMapToIcon,
state,
variant = 'contained',
...restIconButtonProps
}) => {
const mapToIcon = useMemo<IconButtonMapToStateIcon | undefined>(
() => externalMapToIcon ?? (mapPreset && MAP_TO_MAP_PRESET[mapPreset]),
[externalMapToIcon, mapPreset],
); );
const iconButtonContent = useMemo(() => {
let result: ReactNode;
if (mapToIcon) {
const iconElementType: CreatableComponent | undefined = state
? mapToIcon[state] ?? defaultIcon
: defaultIcon;
if (iconElementType) {
result = createElement(iconElementType, iconProps);
}
} else {
result = children;
}
return result;
}, [children, mapToIcon, state, defaultIcon, iconProps]);
const iconButtonElementType = useMemo(
() => MAP_TO_VARIANT[variant],
[variant],
);
return createElement(
iconButtonElementType,
restIconButtonProps,
iconButtonContent,
);
};
export type { IconButtonProps };
export default IconButton; export default IconButton;

@ -22,14 +22,17 @@ type InputWithRefOptionalPropsWithDefault<
required?: boolean; required?: boolean;
valueType?: TypeName; valueType?: TypeName;
}; };
type InputWithRefOptionalPropsWithoutDefault = { type InputWithRefOptionalPropsWithoutDefault<
TypeName extends keyof MapToInputType,
> = {
inputTestBatch?: InputTestBatch; inputTestBatch?: InputTestBatch;
onFirstRender?: (args: { isRequired: boolean }) => void; onFirstRender?: (args: { isRequired: boolean }) => void;
valueKey?: CreateInputOnChangeHandlerOptions<TypeName>['valueKey'];
}; };
type InputWithRefOptionalProps<TypeName extends keyof MapToInputType> = type InputWithRefOptionalProps<TypeName extends keyof MapToInputType> =
InputWithRefOptionalPropsWithDefault<TypeName> & InputWithRefOptionalPropsWithDefault<TypeName> &
InputWithRefOptionalPropsWithoutDefault; InputWithRefOptionalPropsWithoutDefault<TypeName>;
type InputWithRefProps< type InputWithRefProps<
TypeName extends keyof MapToInputType, TypeName extends keyof MapToInputType,
@ -47,6 +50,7 @@ type InputForwardedRefContent<TypeName extends keyof MapToInputType> = {
const INPUT_TEST_ID = 'input'; const INPUT_TEST_ID = 'input';
const MAP_TO_INITIAL_VALUE: MapToInputType = { const MAP_TO_INITIAL_VALUE: MapToInputType = {
boolean: false,
number: 0, number: 0,
string: '', string: '',
}; };
@ -54,7 +58,7 @@ const MAP_TO_INITIAL_VALUE: MapToInputType = {
const INPUT_WITH_REF_DEFAULT_PROPS: Required< const INPUT_WITH_REF_DEFAULT_PROPS: Required<
InputWithRefOptionalPropsWithDefault<'string'> InputWithRefOptionalPropsWithDefault<'string'>
> & > &
InputWithRefOptionalPropsWithoutDefault = { InputWithRefOptionalPropsWithoutDefault<'string'> = {
createInputOnChangeHandlerOptions: {}, createInputOnChangeHandlerOptions: {},
required: false, required: false,
valueType: 'string', valueType: 'string',
@ -63,27 +67,35 @@ const INPUT_WITH_REF_DEFAULT_PROPS: Required<
const InputWithRef = forwardRef( const InputWithRef = forwardRef(
<TypeName extends keyof MapToInputType, InputComponent extends ReactElement>( <TypeName extends keyof MapToInputType, InputComponent extends ReactElement>(
{ {
createInputOnChangeHandlerOptions: {
postSet: postSetAppend,
...restCreateInputOnChangeHandlerOptions
} = INPUT_WITH_REF_DEFAULT_PROPS.createInputOnChangeHandlerOptions as CreateInputOnChangeHandlerOptions<TypeName>,
input, input,
inputTestBatch, inputTestBatch,
onFirstRender, onFirstRender,
required: isRequired = INPUT_WITH_REF_DEFAULT_PROPS.required, required: isRequired = INPUT_WITH_REF_DEFAULT_PROPS.required,
valueKey,
valueType = INPUT_WITH_REF_DEFAULT_PROPS.valueType as TypeName, valueType = INPUT_WITH_REF_DEFAULT_PROPS.valueType as TypeName,
// Props with initial value that depend on others.
createInputOnChangeHandlerOptions: {
postSet: postSetAppend,
valueKey: onChangeValueKey = valueKey,
...restCreateInputOnChangeHandlerOptions
} = INPUT_WITH_REF_DEFAULT_PROPS.createInputOnChangeHandlerOptions as CreateInputOnChangeHandlerOptions<TypeName>,
}: InputWithRefProps<TypeName, InputComponent>, }: InputWithRefProps<TypeName, InputComponent>,
ref: ForwardedRef<InputForwardedRefContent<TypeName>>, ref: ForwardedRef<InputForwardedRefContent<TypeName>>,
) => { ) => {
const { props: inputProps } = input;
const vKey = useMemo(
() => onChangeValueKey ?? ('checked' in inputProps ? 'checked' : 'value'),
[inputProps, onChangeValueKey],
);
const { const {
props: {
onBlur: initOnBlur, onBlur: initOnBlur,
onChange: initOnChange, onChange: initOnChange,
onFocus: initOnFocus, onFocus: initOnFocus,
value: initValue = MAP_TO_INITIAL_VALUE[valueType], [vKey]: initValue = MAP_TO_INITIAL_VALUE[valueType],
...restInitProps ...restInitProps
}, } = inputProps;
} = input;
const isFirstRender = useIsFirstRender(); const isFirstRender = useIsFirstRender();
@ -123,17 +135,9 @@ const InputWithRef = forwardRef(
})), })),
[initOnBlur, testInput], [initOnBlur, testInput],
); );
const onFocus = useMemo<InputBaseProps['onFocus']>( const onChange = useMemo(
() => () =>
initOnFocus ?? createInputOnChangeHandler<TypeName>({
(inputTestBatch &&
(() => {
inputTestBatch.defaults?.onSuccess?.call(null, { append: {} });
})),
[initOnFocus, inputTestBatch],
);
const onChange = createInputOnChangeHandler<TypeName>({
postSet: (...args) => { postSet: (...args) => {
setIsChangedByUser(true); setIsChangedByUser(true);
initOnChange?.call(null, ...args); initOnChange?.call(null, ...args);
@ -141,8 +145,27 @@ const InputWithRef = forwardRef(
}, },
set: setValue, set: setValue,
setType: valueType, setType: valueType,
valueKey: vKey,
...restCreateInputOnChangeHandlerOptions, ...restCreateInputOnChangeHandlerOptions,
}); }),
[
initOnChange,
postSetAppend,
restCreateInputOnChangeHandlerOptions,
setValue,
vKey,
valueType,
],
);
const onFocus = useMemo<InputBaseProps['onFocus']>(
() =>
initOnFocus ??
(inputTestBatch &&
(() => {
inputTestBatch.defaults?.onSuccess?.call(null, { append: {} });
})),
[initOnFocus, inputTestBatch],
);
useEffect(() => { useEffect(() => {
if (isFirstRender) { if (isFirstRender) {
@ -167,7 +190,7 @@ const InputWithRef = forwardRef(
onChange, onChange,
onFocus, onFocus,
required: isRequired, required: isRequired,
value: inputValue, [vKey]: inputValue,
}); });
}, },
); );

@ -8,6 +8,7 @@ import {
Box as MUIBox, Box as MUIBox,
List as MUIList, List as MUIList,
ListItem as MUIListItem, ListItem as MUIListItem,
ListItemButton,
ListItemIcon as MUIListItemIcon, ListItemIcon as MUIListItemIcon,
SxProps, SxProps,
Theme, Theme,
@ -23,7 +24,7 @@ import {
} from 'react'; } from 'react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { BLUE, GREY, RED } from '../lib/consts/DEFAULT_THEME'; import { BLUE, BORDER_RADIUS, GREY, RED } from '../lib/consts/DEFAULT_THEME';
import Checkbox from './Checkbox'; import Checkbox from './Checkbox';
import Divider from './Divider'; import Divider from './Divider';
@ -36,6 +37,7 @@ const List = forwardRef(
{ {
allowCheckAll: isAllowCheckAll = false, allowCheckAll: isAllowCheckAll = false,
allowEdit: isAllowEdit = false, allowEdit: isAllowEdit = false,
allowItemButton: isAllowItemButton = false,
edit: isEdit = false, edit: isEdit = false,
flexBoxProps, flexBoxProps,
header, header,
@ -53,6 +55,7 @@ const List = forwardRef(
onEdit, onEdit,
onAllCheckboxChange, onAllCheckboxChange,
onItemCheckboxChange, onItemCheckboxChange,
onItemClick,
renderListItem = (key) => <BodyText>{key}</BodyText>, renderListItem = (key) => <BodyText>{key}</BodyText>,
renderListItemCheckboxState, renderListItemCheckboxState,
scroll: isScroll = false, scroll: isScroll = false,
@ -140,14 +143,15 @@ const List = forwardRef(
isEdit, isEdit,
onAllCheckboxChange, onAllCheckboxChange,
]); ]);
const headerElement = useMemo( const headerElement = useMemo(() => {
() => const headerType = typeof header;
isInsertHeader && header ? (
return isInsertHeader && header ? (
<FlexBox row spacing={headerSpacing} sx={{ height: '2.4em' }}> <FlexBox row spacing={headerSpacing} sx={{ height: '2.4em' }}>
{checkAllElement} {checkAllElement}
{typeof header === 'string' ? ( {['boolean', 'string'].includes(headerType) ? (
<> <>
<BodyText>{header}</BodyText> {headerType === 'string' && <BodyText>{header}</BodyText>}
<Divider sx={{ flexGrow: 1 }} /> <Divider sx={{ flexGrow: 1 }} />
</> </>
) : ( ) : (
@ -159,8 +163,8 @@ const List = forwardRef(
</FlexBox> </FlexBox>
) : ( ) : (
header header
), );
[ }, [
addItemButton, addItemButton,
checkAllElement, checkAllElement,
deleteItemButton, deleteItemButton,
@ -168,8 +172,7 @@ const List = forwardRef(
header, header,
headerSpacing, headerSpacing,
isInsertHeader, isInsertHeader,
], ]);
);
const listEmptyElement = useMemo( const listEmptyElement = useMemo(
() => () =>
typeof listEmpty === 'string' ? ( typeof listEmpty === 'string' ? (
@ -203,7 +206,10 @@ const List = forwardRef(
const entries = Object.entries(listItems); const entries = Object.entries(listItems);
if (entries.length > 0) { if (entries.length > 0) {
result = entries.map(([key, value]) => ( result = entries.map(([key, value]) => {
const listItem = renderListItem(key, value);
return (
<MUIListItem <MUIListItem
{...restListItemProps} {...restListItemProps}
key={`${listItemKeyPrefix}-${key}`} key={`${listItemKeyPrefix}-${key}`}
@ -213,22 +219,36 @@ const List = forwardRef(
key, key,
renderListItemCheckboxState?.call(null, key, value), renderListItemCheckboxState?.call(null, key, value),
)} )}
{renderListItem(key, value)} {isAllowItemButton ? (
<ListItemButton
onClick={(...args) => {
onItemClick?.call(null, value, key, ...args);
}}
sx={{ borderRadius: BORDER_RADIUS }}
>
{listItem}
</ListItemButton>
) : (
listItem
)}
</MUIListItem> </MUIListItem>
)); );
});
} }
} }
return result; return result;
}, [ }, [
listEmptyElement, listEmptyElement,
listItemCheckbox,
listItemKeyPrefix,
listItems, listItems,
listItemSx,
renderListItem, renderListItem,
renderListItemCheckboxState,
restListItemProps, restListItemProps,
listItemKeyPrefix,
listItemSx,
listItemCheckbox,
renderListItemCheckboxState,
isAllowItemButton,
onItemClick,
]); ]);
const listScrollSx: SxProps<Theme> | undefined = useMemo( const listScrollSx: SxProps<Theme> | undefined = useMemo(
() => (isScroll ? { maxHeight: '100%', overflowY: 'scroll' } : undefined), () => (isScroll ? { maxHeight: '100%', overflowY: 'scroll' } : undefined),

@ -0,0 +1,108 @@
import { Box } from '@mui/material';
import { FC, useMemo, useState } from 'react';
import Autocomplete from '../Autocomplete';
import CommonFenceInputGroup from './CommonFenceInputGroup';
import FlexBox from '../FlexBox';
import Spinner from '../Spinner';
import { BodyText } from '../Text';
const AddFenceInputGroup: FC<AddFenceInputGroupProps> = ({
fenceTemplate: externalFenceTemplate,
loading: isExternalLoading,
}) => {
const [fenceTypeValue, setInputFenceTypeValue] =
useState<FenceAutocompleteOption | null>(null);
const fenceTypeOptions = useMemo<FenceAutocompleteOption[]>(
() =>
externalFenceTemplate
? Object.entries(externalFenceTemplate)
.sort(([a], [b]) => (a > b ? 1 : -1))
.map(([id, { description: rawDescription }]) => {
const description =
typeof rawDescription === 'string'
? rawDescription
: 'No description.';
return {
fenceDescription: description,
fenceId: id,
label: id,
};
})
: [],
[externalFenceTemplate],
);
const fenceTypeElement = useMemo(
() => (
<Autocomplete
id="add-fence-select-type"
isOptionEqualToValue={(option, value) =>
option.fenceId === value.fenceId
}
label="Fence device type"
onChange={(event, newFenceType) => {
setInputFenceTypeValue(newFenceType);
}}
openOnFocus
options={fenceTypeOptions}
renderOption={(props, { fenceDescription, fenceId }, { selected }) => (
<Box
component="li"
sx={{
display: 'flex',
flexDirection: 'column',
'& > *': {
width: '100%',
},
}}
{...props}
>
<BodyText
inverted
sx={{
fontSize: '1.2em',
fontWeight: selected ? 400 : undefined,
}}
>
{fenceId}
</BodyText>
<BodyText selected={false}>{fenceDescription}</BodyText>
</Box>
)}
sx={{ marginTop: '.3em' }}
value={fenceTypeValue}
/>
),
[fenceTypeOptions, fenceTypeValue],
);
const fenceParameterElements = useMemo(
() => (
<CommonFenceInputGroup
fenceId={fenceTypeValue?.fenceId}
fenceTemplate={externalFenceTemplate}
/>
),
[externalFenceTemplate, fenceTypeValue],
);
const content = useMemo(
() =>
isExternalLoading ? (
<Spinner />
) : (
<FlexBox>
{fenceTypeElement}
{fenceParameterElements}
</FlexBox>
),
[fenceTypeElement, fenceParameterElements, isExternalLoading],
);
return <>{content}</>;
};
export default AddFenceInputGroup;

@ -0,0 +1,246 @@
import { Box, styled, Tooltip } from '@mui/material';
import { FC, ReactElement, ReactNode, useMemo } from 'react';
import INPUT_TYPES from '../../lib/consts/INPUT_TYPES';
import FlexBox from '../FlexBox';
import InputWithRef from '../InputWithRef';
import OutlinedInputWithLabel from '../OutlinedInputWithLabel';
import { ExpandablePanel } from '../Panels';
import SelectWithLabel from '../SelectWithLabel';
import SwitchWithLabel from '../SwitchWithLabel';
import { BodyText } from '../Text';
const CHECKED_STATES: Array<string | undefined> = ['1', 'on'];
const ID_SEPARATOR = '-';
const MAP_TO_INPUT_BUILDER: MapToInputBuilder = {
boolean: (args) => {
const { id, isChecked = false, label, name = id } = args;
return (
<InputWithRef
key={`${id}-wrapper`}
input={
<SwitchWithLabel
checked={isChecked}
flexBoxProps={{ width: '100%' }}
id={id}
label={label}
name={name}
/>
}
valueType="boolean"
/>
);
},
select: (args) => {
const {
id,
isRequired,
label,
name = id,
selectOptions = [],
value = '',
} = args;
return (
<InputWithRef
key={`${id}-wrapper`}
input={
<SelectWithLabel
id={id}
label={label}
name={name}
selectItems={selectOptions}
value={value}
/>
}
required={isRequired}
/>
);
},
string: (args) => {
const {
id,
isRequired,
isSensitive = false,
label = '',
name = id,
value,
} = args;
return (
<InputWithRef
key={`${id}-wrapper`}
input={
<OutlinedInputWithLabel
id={id}
inputProps={{
inputProps: { 'data-sensitive': isSensitive },
}}
label={label}
name={name}
value={value}
type={isSensitive ? INPUT_TYPES.password : undefined}
/>
}
required={isRequired}
/>
);
},
};
const combineIds = (...pieces: string[]) => pieces.join(ID_SEPARATOR);
const FenceInputWrapper = styled(FlexBox)({
margin: '.4em 0',
});
const CommonFenceInputGroup: FC<CommonFenceInputGroupProps> = ({
fenceId,
fenceParameterTooltipProps,
fenceTemplate,
previousFenceName,
previousFenceParameters,
}) => {
const fenceParameterElements = useMemo(() => {
let result: ReactNode;
if (fenceTemplate && fenceId) {
const { parameters: fenceParameters } = fenceTemplate[fenceId];
let mapToPreviousFenceParameterValues: FenceParameters = {};
if (previousFenceParameters) {
mapToPreviousFenceParameterValues = Object.entries(
previousFenceParameters,
).reduce<FenceParameters>((previous, [parameterId, parameterValue]) => {
const newKey = combineIds(fenceId, parameterId);
previous[newKey] = parameterValue;
return previous;
}, {});
}
const { optional: optionalInputs, required: requiredInputs } =
Object.entries(fenceParameters)
.sort(([a], [b]) => (a > b ? 1 : -1))
.reduce<{
optional: ReactElement[];
required: ReactElement[];
}>(
(
previous,
[
parameterId,
{
content_type: parameterType,
default: parameterDefault,
deprecated: rawParameterDeprecated,
description: parameterDescription,
options: parameterSelectOptions,
required: rawParameterRequired,
},
],
) => {
const isParameterDeprecated =
String(rawParameterDeprecated) === '1';
if (!isParameterDeprecated) {
const { optional, required } = previous;
const buildInput =
MAP_TO_INPUT_BUILDER[parameterType] ??
MAP_TO_INPUT_BUILDER.string;
const fenceJoinParameterId = combineIds(fenceId, parameterId);
const initialValue =
mapToPreviousFenceParameterValues[fenceJoinParameterId] ??
parameterDefault;
const isParameterRequired =
String(rawParameterRequired) === '1';
const isParameterSensitive = /passw/i.test(parameterId);
const parameterInput = buildInput({
id: fenceJoinParameterId,
isChecked: CHECKED_STATES.includes(initialValue),
isRequired: isParameterRequired,
isSensitive: isParameterSensitive,
label: parameterId,
selectOptions: parameterSelectOptions,
value: initialValue,
});
const parameterInputWithTooltip = (
<Tooltip
componentsProps={{
tooltip: {
sx: {
maxWidth: { md: '62.6em' },
},
},
}}
disableInteractive
key={`${fenceJoinParameterId}-tooltip`}
placement="top-start"
title={<BodyText>{parameterDescription}</BodyText>}
{...fenceParameterTooltipProps}
>
<Box>{parameterInput}</Box>
</Tooltip>
);
if (isParameterRequired) {
required.push(parameterInputWithTooltip);
} else {
optional.push(parameterInputWithTooltip);
}
}
return previous;
},
{
optional: [],
required: [
MAP_TO_INPUT_BUILDER.string({
id: combineIds(fenceId, 'name'),
isRequired: true,
label: 'Fence device name',
value: previousFenceName,
}),
],
},
);
result = (
<FlexBox
sx={{
'& > div:first-child': { marginTop: 0 },
'& > div': { marginBottom: 0 },
}}
>
<ExpandablePanel expandInitially header="Required parameters">
<FenceInputWrapper>{requiredInputs}</FenceInputWrapper>
</ExpandablePanel>
<ExpandablePanel header="Optional parameters">
<FenceInputWrapper>{optionalInputs}</FenceInputWrapper>
</ExpandablePanel>
</FlexBox>
);
}
return result;
}, [
fenceId,
fenceParameterTooltipProps,
fenceTemplate,
previousFenceName,
previousFenceParameters,
]);
return <>{fenceParameterElements}</>;
};
export { ID_SEPARATOR };
export default CommonFenceInputGroup;

@ -0,0 +1,37 @@
import { FC, useMemo } from 'react';
import CommonFenceInputGroup from './CommonFenceInputGroup';
import Spinner from '../Spinner';
const EditFenceInputGroup: FC<EditFenceInputGroupProps> = ({
fenceId,
fenceTemplate: externalFenceTemplate,
loading: isExternalLoading,
previousFenceName,
previousFenceParameters,
}) => {
const content = useMemo(
() =>
isExternalLoading ? (
<Spinner />
) : (
<CommonFenceInputGroup
fenceId={fenceId}
fenceTemplate={externalFenceTemplate}
previousFenceName={previousFenceName}
previousFenceParameters={previousFenceParameters}
/>
),
[
externalFenceTemplate,
fenceId,
isExternalLoading,
previousFenceName,
previousFenceParameters,
],
);
return <>{content}</>;
};
export default EditFenceInputGroup;

@ -0,0 +1,356 @@
import { Box } from '@mui/material';
import {
FC,
FormEventHandler,
ReactElement,
ReactNode,
useMemo,
useRef,
useState,
} from 'react';
import API_BASE_URL from '../../lib/consts/API_BASE_URL';
import AddFenceInputGroup from './AddFenceInputGroup';
import api from '../../lib/api';
import { ID_SEPARATOR } from './CommonFenceInputGroup';
import ConfirmDialog from '../ConfirmDialog';
import EditFenceInputGroup from './EditFenceInputGroup';
import FlexBox from '../FlexBox';
import handleAPIError from '../../lib/handleAPIError';
import List from '../List';
import { Panel, PanelHeader } from '../Panels';
import periodicFetch from '../../lib/fetchers/periodicFetch';
import Spinner from '../Spinner';
import {
BodyText,
HeaderText,
InlineMonoText,
MonoText,
SensitiveText,
SmallText,
} from '../Text';
import useIsFirstRender from '../../hooks/useIsFirstRender';
import useProtectedState from '../../hooks/useProtectedState';
type FormFenceParameterData = {
fenceAgent: string;
fenceName: string;
parameterInputs: {
[parameterInputId: string]: {
isParameterSensitive: boolean;
parameterId: string;
parameterType: string;
parameterValue: string;
};
};
};
const fenceParameterBooleanToString = (value: boolean) => (value ? '1' : '0');
const getFormFenceParameters = (
fenceTemplate: APIFenceTemplate,
...[{ target }]: Parameters<FormEventHandler<HTMLDivElement>>
) => {
const { elements } = target as HTMLFormElement;
return Object.values(elements).reduce<FormFenceParameterData>(
(previous, formElement) => {
const { id: inputId } = formElement;
const reExtract = new RegExp(`^(fence[^-]+)${ID_SEPARATOR}([^\\s]+)$`);
const matched = inputId.match(reExtract);
if (matched) {
const [, fenceId, parameterId] = matched;
previous.fenceAgent = fenceId;
const inputElement = formElement as HTMLInputElement;
const {
checked,
dataset: { sensitive: rawSensitive },
value,
} = inputElement;
if (parameterId === 'name') {
previous.fenceName = value;
}
const {
[fenceId]: {
parameters: {
[parameterId]: { content_type: parameterType = 'string' } = {},
},
},
} = fenceTemplate;
previous.parameterInputs[inputId] = {
isParameterSensitive: rawSensitive === 'true',
parameterId,
parameterType,
parameterValue:
parameterType === 'boolean'
? fenceParameterBooleanToString(checked)
: value,
};
}
return previous;
},
{ fenceAgent: '', fenceName: '', parameterInputs: {} },
);
};
const buildConfirmFenceParameters = (
parameterInputs: FormFenceParameterData['parameterInputs'],
) => (
<List
listItems={parameterInputs}
listItemProps={{ sx: { padding: 0 } }}
renderListItem={(
parameterInputId,
{ isParameterSensitive, parameterId, parameterValue },
) => {
let textElement: ReactElement;
if (parameterValue) {
textElement = isParameterSensitive ? (
<SensitiveText monospaced>{parameterValue}</SensitiveText>
) : (
<Box sx={{ maxWidth: '100%', overflowX: 'scroll' }}>
<MonoText lineHeight={2.8} whiteSpace="nowrap">
{parameterValue}
</MonoText>
</Box>
);
} else {
textElement = <SmallText>none</SmallText>;
}
return (
<FlexBox
fullWidth
growFirst
height="2.8em"
key={`confirm-${parameterInputId}`}
maxWidth="100%"
row
>
<BodyText>{parameterId}</BodyText>
{textElement}
</FlexBox>
);
}}
/>
);
const ManageFencePanel: FC = () => {
const isFirstRender = useIsFirstRender();
const confirmDialogRef = useRef<ConfirmDialogForwardedRefContent>({});
const formDialogRef = useRef<ConfirmDialogForwardedRefContent>({});
const [confirmDialogProps, setConfirmDialogProps] =
useState<ConfirmDialogProps>({
actionProceedText: '',
content: '',
titleText: '',
});
const [formDialogProps, setFormDialogProps] = useState<ConfirmDialogProps>({
actionProceedText: '',
content: '',
titleText: '',
});
const [fenceTemplate, setFenceTemplate] = useProtectedState<
APIFenceTemplate | undefined
>(undefined);
const [isEditFences, setIsEditFences] = useState<boolean>(false);
const [isLoadingFenceTemplate, setIsLoadingFenceTemplate] =
useProtectedState<boolean>(true);
const { data: fenceOverviews, isLoading: isFenceOverviewsLoading } =
periodicFetch<APIFenceOverview>(`${API_BASE_URL}/fence`, {
refreshInterval: 60000,
});
const listElement = useMemo(
() => (
<List
allowEdit
allowItemButton={isEditFences}
edit={isEditFences}
header
listItems={fenceOverviews}
onAdd={() => {
setFormDialogProps({
actionProceedText: 'Add',
content: <AddFenceInputGroup fenceTemplate={fenceTemplate} />,
onSubmitAppend: (event) => {
if (!fenceTemplate) {
return;
}
const addData = getFormFenceParameters(fenceTemplate, event);
setConfirmDialogProps({
actionProceedText: 'Add',
content: buildConfirmFenceParameters(addData.parameterInputs),
titleText: (
<HeaderText>
Add a{' '}
<InlineMonoText fontSize="inherit">
{addData.fenceAgent}
</InlineMonoText>{' '}
fence device with the following parameters?
</HeaderText>
),
});
confirmDialogRef.current.setOpen?.call(null, true);
},
titleText: 'Add a fence device',
});
formDialogRef.current.setOpen?.call(null, true);
}}
onEdit={() => {
setIsEditFences((previous) => !previous);
}}
onItemClick={({ fenceAgent: fenceId, fenceName, fenceParameters }) => {
setFormDialogProps({
actionProceedText: 'Update',
content: (
<EditFenceInputGroup
fenceId={fenceId}
fenceTemplate={fenceTemplate}
previousFenceName={fenceName}
previousFenceParameters={fenceParameters}
/>
),
onSubmitAppend: (event) => {
if (!fenceTemplate) {
return;
}
const editData = getFormFenceParameters(fenceTemplate, event);
setConfirmDialogProps({
actionProceedText: 'Update',
content: buildConfirmFenceParameters(editData.parameterInputs),
titleText: (
<HeaderText>
Update{' '}
<InlineMonoText fontSize="inherit">
{editData.fenceName}
</InlineMonoText>{' '}
fence device with the following parameters?
</HeaderText>
),
});
confirmDialogRef.current.setOpen?.call(null, true);
},
titleText: (
<HeaderText>
Update fence device{' '}
<InlineMonoText fontSize="inherit">{fenceName}</InlineMonoText>{' '}
parameters
</HeaderText>
),
});
formDialogRef.current.setOpen?.call(null, true);
}}
renderListItem={(
fenceUUID,
{ fenceAgent, fenceName, fenceParameters },
) => (
<FlexBox row>
<BodyText>{fenceName}</BodyText>
<BodyText>
{Object.entries(fenceParameters).reduce<ReactNode>(
(previous, [parameterId, parameterValue]) => {
let current: ReactNode = <>{parameterId}=&quot;</>;
current = /passw/i.test(parameterId) ? (
<>
{current}
<SensitiveText inline>{parameterValue}</SensitiveText>
</>
) : (
<>
{current}
{parameterValue}
</>
);
return (
<>
{previous} {current}&quot;
</>
);
},
fenceAgent,
)}
</BodyText>
</FlexBox>
)}
/>
),
[fenceOverviews, fenceTemplate, isEditFences],
);
const panelContent = useMemo(
() =>
isLoadingFenceTemplate || isFenceOverviewsLoading ? (
<Spinner />
) : (
listElement
),
[isFenceOverviewsLoading, isLoadingFenceTemplate, listElement],
);
if (isFirstRender) {
api
.get<APIFenceTemplate>(`/fence/template`)
.then(({ data }) => {
setFenceTemplate(data);
})
.catch((error) => {
handleAPIError(error);
})
.finally(() => {
setIsLoadingFenceTemplate(false);
});
}
return (
<>
<Panel>
<PanelHeader>
<HeaderText>Manage fence devices</HeaderText>
</PanelHeader>
{panelContent}
</Panel>
<ConfirmDialog
dialogProps={{
PaperProps: { sx: { minWidth: { xs: '90%', md: '50em' } } },
}}
formContent
scrollBoxProps={{
padding: '.3em .5em',
}}
scrollContent
{...formDialogProps}
ref={formDialogRef}
/>
<ConfirmDialog
scrollBoxProps={{ paddingRight: '1em' }}
scrollContent
{...confirmDialogProps}
ref={confirmDialogRef}
/>
</>
);
};
export default ManageFencePanel;

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

@ -31,9 +31,6 @@ type OutlinedInputWithLabelOptionalPropsWithDefault = {
}; };
type OutlinedInputWithLabelOptionalPropsWithoutDefault = { type OutlinedInputWithLabelOptionalPropsWithoutDefault = {
onBlur?: OutlinedInputProps['onBlur'];
onChange?: OutlinedInputProps['onChange'];
onFocus?: OutlinedInputProps['onFocus'];
onHelp?: MUIIconButtonProps['onClick']; onHelp?: MUIIconButtonProps['onClick'];
onHelpAppend?: MUIIconButtonProps['onClick']; onHelpAppend?: MUIIconButtonProps['onClick'];
type?: string; type?: string;
@ -43,7 +40,11 @@ type OutlinedInputWithLabelOptionalProps =
OutlinedInputWithLabelOptionalPropsWithDefault & OutlinedInputWithLabelOptionalPropsWithDefault &
OutlinedInputWithLabelOptionalPropsWithoutDefault; OutlinedInputWithLabelOptionalPropsWithoutDefault;
type OutlinedInputWithLabelProps = OutlinedInputWithLabelOptionalProps & { type OutlinedInputWithLabelProps = Pick<
OutlinedInputProps,
'name' | 'onBlur' | 'onChange' | 'onFocus'
> &
OutlinedInputWithLabelOptionalProps & {
label: string; label: string;
}; };
@ -56,9 +57,6 @@ const OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS: Required<OutlinedInputWithLabelOp
inputProps: {}, inputProps: {},
inputLabelProps: {}, inputLabelProps: {},
messageBoxProps: {}, messageBoxProps: {},
onBlur: undefined,
onChange: undefined,
onFocus: undefined,
onHelp: undefined, onHelp: undefined,
onHelpAppend: undefined, onHelpAppend: undefined,
required: false, required: false,
@ -78,6 +76,7 @@ const OutlinedInputWithLabel: FC<OutlinedInputWithLabelProps> = ({
inputLabelProps = OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS.inputLabelProps, inputLabelProps = OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS.inputLabelProps,
label, label,
messageBoxProps = OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS.messageBoxProps, messageBoxProps = OUTLINED_INPUT_WITH_LABEL_DEFAULT_PROPS.messageBoxProps,
name,
onBlur, onBlur,
onChange, onChange,
onFocus, onFocus,
@ -133,6 +132,7 @@ const OutlinedInputWithLabel: FC<OutlinedInputWithLabelProps> = ({
return ( return (
<MUIFormControl <MUIFormControl
fullWidth
{...restFormControlProps} {...restFormControlProps}
sx={{ width: formControlWidth, ...formControlSx }} sx={{ width: formControlWidth, ...formControlSx }}
> >
@ -172,6 +172,7 @@ const OutlinedInputWithLabel: FC<OutlinedInputWithLabelProps> = ({
fullWidth={formControlProps.fullWidth} fullWidth={formControlProps.fullWidth}
id={id} id={id}
label={label} label={label}
name={name}
onBlur={onBlur} onBlur={onBlur}
onChange={onChange} onChange={onChange}
onFocus={onFocus} onFocus={onFocus}

@ -12,7 +12,7 @@ import { MessageBoxProps } from './MessageBox';
import OutlinedInputWithLabel, { import OutlinedInputWithLabel, {
OutlinedInputWithLabelProps, OutlinedInputWithLabelProps,
} from './OutlinedInputWithLabel'; } from './OutlinedInputWithLabel';
import SelectWithLabel, { SelectWithLabelProps } from './SelectWithLabel'; import SelectWithLabel from './SelectWithLabel';
type OutlinedLabeledInputWithSelectOptionalProps = { type OutlinedLabeledInputWithSelectOptionalProps = {
inputWithLabelProps?: Partial<OutlinedInputWithLabelProps>; inputWithLabelProps?: Partial<OutlinedInputWithLabelProps>;
@ -66,19 +66,11 @@ const OutlinedLabeledInputWithSelect: FC<
}, },
}} }}
> >
<OutlinedInputWithLabel <OutlinedInputWithLabel id={id} label={label} {...inputWithLabelProps} />
{...{
id,
label,
...inputWithLabelProps,
}}
/>
<SelectWithLabel <SelectWithLabel
{...{ id={`${id}-nested-select`}
id: `${id}-nested-select`, selectItems={selectItems}
selectItems, {...selectWithLabelProps}
...selectWithLabelProps,
}}
/> />
</Box> </Box>
<InputMessageBox {...messageBoxProps} /> <InputMessageBox {...messageBoxProps} />

@ -3,7 +3,7 @@ import {
ExpandMore as ExpandMoreIcon, ExpandMore as ExpandMoreIcon,
} from '@mui/icons-material'; } from '@mui/icons-material';
import { Box, IconButton } from '@mui/material'; import { Box, IconButton } from '@mui/material';
import { FC, ReactNode, useMemo, useState } from 'react'; import { FC, useMemo, useState } from 'react';
import { GREY } from '../../lib/consts/DEFAULT_THEME'; import { GREY } from '../../lib/consts/DEFAULT_THEME';
@ -12,32 +12,17 @@ import InnerPanel from './InnerPanel';
import InnerPanelBody from './InnerPanelBody'; import InnerPanelBody from './InnerPanelBody';
import InnerPanelHeader from './InnerPanelHeader'; import InnerPanelHeader from './InnerPanelHeader';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
import { BodyText } from '../Text';
type ExpandablePanelOptionalProps = {
expandInitially?: boolean;
loading?: boolean;
showHeaderSpinner?: boolean;
};
type ExpandablePanelProps = ExpandablePanelOptionalProps & {
header: ReactNode;
};
const EXPANDABLE_PANEL_DEFAULT_PROPS: Required<ExpandablePanelOptionalProps> = {
expandInitially: false,
loading: false,
showHeaderSpinner: false,
};
const HEADER_SPINNER_LENGTH = '1.2em'; const HEADER_SPINNER_LENGTH = '1.2em';
const ExpandablePanel: FC<ExpandablePanelProps> = ({ const ExpandablePanel: FC<ExpandablePanelProps> = ({
children, children,
expandInitially: expandInitially: isExpandInitially = false,
isExpandInitially = EXPANDABLE_PANEL_DEFAULT_PROPS.expandInitially,
header, header,
loading: isLoading = EXPANDABLE_PANEL_DEFAULT_PROPS.loading, loading: isLoading = false,
showHeaderSpinner: panelProps,
isShowHeaderSpinner = EXPANDABLE_PANEL_DEFAULT_PROPS.showHeaderSpinner, showHeaderSpinner: isShowHeaderSpinner = false,
}) => { }) => {
const [isExpand, setIsExpand] = useState<boolean>(isExpandInitially); const [isExpand, setIsExpand] = useState<boolean>(isExpandInitially);
@ -46,6 +31,10 @@ const ExpandablePanel: FC<ExpandablePanelProps> = ({
[isExpand], [isExpand],
); );
const contentHeight = useMemo(() => (isExpand ? 'auto' : '.2em'), [isExpand]); const contentHeight = useMemo(() => (isExpand ? 'auto' : '.2em'), [isExpand]);
const headerElement = useMemo(
() => (typeof header === 'string' ? <BodyText>{header}</BodyText> : header),
[header],
);
const headerSpinner = useMemo( const headerSpinner = useMemo(
() => () =>
isShowHeaderSpinner && !isExpand && isLoading ? ( isShowHeaderSpinner && !isExpand && isLoading ? (
@ -71,10 +60,10 @@ const ExpandablePanel: FC<ExpandablePanelProps> = ({
); );
return ( return (
<InnerPanel> <InnerPanel {...panelProps}>
<InnerPanelHeader> <InnerPanelHeader>
<FlexBox row> <FlexBox row>
{header} {headerElement}
{headerSpinner} {headerSpinner}
</FlexBox> </FlexBox>
<IconButton <IconButton
@ -91,6 +80,4 @@ const ExpandablePanel: FC<ExpandablePanelProps> = ({
); );
}; };
ExpandablePanel.defaultProps = EXPANDABLE_PANEL_DEFAULT_PROPS;
export default ExpandablePanel; export default ExpandablePanel;

@ -1,14 +1,11 @@
import { FC } from 'react'; import { FC, useMemo } from 'react';
import { Box as MUIBox, BoxProps as MUIBoxProps } from '@mui/material'; import { Box as MUIBox, SxProps, Theme } from '@mui/material';
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME'; import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME';
type InnerPanelProps = MUIBoxProps; const InnerPanel: FC<InnerPanelProps> = ({ sx, ...muiBoxRestProps }) => {
const combinedSx = useMemo<SxProps<Theme>>(
const InnerPanel: FC<InnerPanelProps> = ({ sx, ...muiBoxRestProps }) => ( () => ({
<MUIBox
{...{
sx: {
borderWidth: '1px', borderWidth: '1px',
borderRadius: BORDER_RADIUS, borderRadius: BORDER_RADIUS,
borderStyle: 'solid', borderStyle: 'solid',
@ -19,10 +16,11 @@ const InnerPanel: FC<InnerPanelProps> = ({ sx, ...muiBoxRestProps }) => (
position: 'relative', position: 'relative',
...sx, ...sx,
}, }),
...muiBoxRestProps, [sx],
}}
/>
); );
return <MUIBox {...muiBoxRestProps} sx={combinedSx} />;
};
export default InnerPanel; export default InnerPanel;

@ -1,45 +1,25 @@
import { FC } from 'react'; import { Close as CloseIcon } from '@mui/icons-material';
import { import {
IconButton as MUIIconButton, IconButton as MUIIconButton,
IconButtonProps as MUIIconButtonProps,
iconButtonClasses as muiIconButtonClasses, iconButtonClasses as muiIconButtonClasses,
inputClasses, inputClasses,
Select as MUISelect, Select as MUISelect,
selectClasses as muiSelectClasses, selectClasses as muiSelectClasses,
SelectProps as MUISelectProps,
InputAdornment as MUIInputAdornment, InputAdornment as MUIInputAdornment,
inputAdornmentClasses as muiInputAdornmentClasses, inputAdornmentClasses as muiInputAdornmentClasses,
} from '@mui/material'; } from '@mui/material';
import { Close as CloseIcon } from '@mui/icons-material'; import { FC, useMemo } from 'react';
import { GREY } from '../lib/consts/DEFAULT_THEME'; import { GREY } from '../lib/consts/DEFAULT_THEME';
type SelectOptionalProps = { const Select: FC<SelectProps> = ({
onClearIndicatorClick?: MUIIconButtonProps['onClick'] | null; onClearIndicatorClick,
};
type SelectProps = MUISelectProps & SelectOptionalProps;
const SELECT_DEFAULT_PROPS: Required<SelectOptionalProps> = {
onClearIndicatorClick: null,
};
const Select: FC<SelectProps> = (selectProps) => {
const {
onClearIndicatorClick = SELECT_DEFAULT_PROPS.onClearIndicatorClick,
...muiSelectProps ...muiSelectProps
} = selectProps; }) => {
const { children, sx, value } = muiSelectProps; const { sx: selectSx, value, ...restMuiSelectProps } = muiSelectProps;
const clearIndicator: JSX.Element | undefined =
String(value).length > 0 && onClearIndicatorClick ? (
<MUIInputAdornment position="end">
<MUIIconButton onClick={onClearIndicatorClick}>
<CloseIcon fontSize="small" />
</MUIIconButton>
</MUIInputAdornment>
) : undefined;
const combinedSx = { const combinedSx = useMemo(
() => ({
[`& .${muiSelectClasses.icon}`]: { [`& .${muiSelectClasses.icon}`]: {
color: GREY, color: GREY,
}, },
@ -59,24 +39,32 @@ const Select: FC<SelectProps> = (selectProps) => {
visibility: 'visible', visibility: 'visible',
}, },
...sx, ...selectSx,
}; }),
[selectSx],
);
const clearIndicatorElement = useMemo(
() =>
String(value).length > 0 &&
onClearIndicatorClick && (
<MUIInputAdornment position="end">
<MUIIconButton onClick={onClearIndicatorClick}>
<CloseIcon fontSize="small" />
</MUIIconButton>
</MUIInputAdornment>
),
[onClearIndicatorClick, value],
);
return ( return (
<MUISelect <MUISelect
{...{ endAdornment={clearIndicatorElement}
endAdornment: clearIndicator, value={value}
...muiSelectProps, {...restMuiSelectProps}
sx: combinedSx, sx={combinedSx}
}} />
>
{children}
</MUISelect>
); );
}; };
Select.defaultProps = SELECT_DEFAULT_PROPS;
export type { SelectProps };
export default Select; export default Select;

@ -1,48 +1,15 @@
import { FC } from 'react';
import { import {
Checkbox as MUICheckbox, Checkbox as MUICheckbox,
FormControl as MUIFormControl, FormControl as MUIFormControl,
selectClasses as muiSelectClasses, selectClasses as muiSelectClasses,
} from '@mui/material'; } from '@mui/material';
import { FC, useCallback, useMemo } from 'react';
import InputMessageBox from './InputMessageBox'; import InputMessageBox from './InputMessageBox';
import MenuItem from './MenuItem'; import MenuItem from './MenuItem';
import { MessageBoxProps } from './MessageBox';
import OutlinedInput from './OutlinedInput'; import OutlinedInput from './OutlinedInput';
import OutlinedInputLabel, { import OutlinedInputLabel from './OutlinedInputLabel';
OutlinedInputLabelProps, import Select from './Select';
} from './OutlinedInputLabel';
import Select, { SelectProps } from './Select';
type SelectWithLabelOptionalProps = {
checkItem?: ((value: string) => boolean) | null;
disableItem?: ((value: string) => boolean) | null;
hideItem?: ((value: string) => boolean) | null;
isCheckableItems?: boolean;
isReadOnly?: boolean;
inputLabelProps?: Partial<OutlinedInputLabelProps>;
label?: string | null;
messageBoxProps?: Partial<MessageBoxProps>;
selectProps?: Partial<SelectProps>;
};
type SelectWithLabelProps = SelectWithLabelOptionalProps & {
id: string;
selectItems: SelectItem[];
};
const SELECT_WITH_LABEL_DEFAULT_PROPS: Required<SelectWithLabelOptionalProps> =
{
checkItem: null,
disableItem: null,
hideItem: null,
isReadOnly: false,
isCheckableItems: false,
inputLabelProps: {},
label: null,
messageBoxProps: {},
selectProps: {},
};
const SelectWithLabel: FC<SelectWithLabelProps> = ({ const SelectWithLabel: FC<SelectWithLabelProps> = ({
id, id,
@ -50,37 +17,45 @@ const SelectWithLabel: FC<SelectWithLabelProps> = ({
selectItems, selectItems,
checkItem, checkItem,
disableItem, disableItem,
formControlProps,
hideItem, hideItem,
inputLabelProps, inputLabelProps = {},
isReadOnly, isReadOnly = false,
messageBoxProps, messageBoxProps = {},
selectProps, name,
isCheckableItems = selectProps?.multiple, onChange,
}) => ( selectProps: {
<MUIFormControl> multiple: selectMultiple,
{label && ( sx: selectSx,
<OutlinedInputLabel {...{ htmlFor: id, ...inputLabelProps }}> ...restSelectProps
{label} } = {},
</OutlinedInputLabel> value: selectValue,
)} // Props with initial value that depend on others.
<Select isCheckableItems = selectMultiple,
{...{ }) => {
id, const combinedSx = useMemo(
input: <OutlinedInput {...{ label }} />, () =>
readOnly: isReadOnly, isReadOnly
...selectProps,
sx: isReadOnly
? { ? {
[`& .${muiSelectClasses.icon}`]: { [`& .${muiSelectClasses.icon}`]: {
visibility: 'hidden', visibility: 'hidden',
}, },
...selectProps?.sx, ...selectSx,
} }
: selectProps?.sx, : selectSx,
}} [isReadOnly, selectSx],
> );
{selectItems.map(({ value, displayValue = value }) => (
const createCheckbox = useCallback(
(value) =>
isCheckableItems && (
<MUICheckbox checked={checkItem?.call(null, value)} />
),
[checkItem, isCheckableItems],
);
const createMenuItem = useCallback(
(value, displayValue) => (
<MenuItem <MenuItem
disabled={disableItem?.call(null, value)} disabled={disableItem?.call(null, value)}
key={`${id}-${value}`} key={`${id}-${value}`}
@ -89,19 +64,53 @@ const SelectWithLabel: FC<SelectWithLabelProps> = ({
}} }}
value={value} value={value}
> >
{isCheckableItems && ( {createCheckbox(value)}
<MUICheckbox checked={checkItem?.call(null, value)} />
)}
{displayValue} {displayValue}
</MenuItem> </MenuItem>
))} ),
[createCheckbox, disableItem, hideItem, id],
);
const inputElement = useMemo(() => <OutlinedInput label={label} />, [label]);
const labelElement = useMemo(
() =>
label && (
<OutlinedInputLabel htmlFor={id} {...inputLabelProps}>
{label}
</OutlinedInputLabel>
),
[id, inputLabelProps, label],
);
const menuItemElements = useMemo(
() =>
selectItems.map((item) => {
const { value, displayValue = value }: SelectItem =
typeof item === 'string' ? { value: item } : item;
return createMenuItem(value, displayValue);
}),
[createMenuItem, selectItems],
);
return (
<MUIFormControl fullWidth {...formControlProps}>
{labelElement}
<Select
id={id}
input={inputElement}
multiple={selectMultiple}
name={name}
onChange={onChange}
readOnly={isReadOnly}
value={selectValue}
{...restSelectProps}
sx={combinedSx}
>
{menuItemElements}
</Select> </Select>
<InputMessageBox {...messageBoxProps} /> <InputMessageBox {...messageBoxProps} />
</MUIFormControl> </MUIFormControl>
); );
};
SelectWithLabel.defaultProps = SELECT_WITH_LABEL_DEFAULT_PROPS;
export type { SelectWithLabelProps };
export default SelectWithLabel; export default SelectWithLabel;

@ -127,10 +127,7 @@ const ConfigPeersForm: FC<ConfigPeerFormProps> = ({
return ( return (
<> <>
<ExpandablePanel <ExpandablePanel header="Configure striker peers" loading={isLoading}>
header={<BodyText>Configure striker peers</BodyText>}
loading={isLoading}
>
<Grid columns={{ xs: 1, sm: 2 }} container spacing="1em"> <Grid columns={{ xs: 1, sm: 2 }} container spacing="1em">
<Grid item xs={1}> <Grid item xs={1}>
<List <List

@ -85,10 +85,7 @@ const ManageChangedSSHKeysForm: FC<ManageChangedSSHKeysFormProps> = ({
return ( return (
<> <>
<ExpandablePanel <ExpandablePanel header="Manage changed SSH keys" loading={isLoading}>
header={<BodyText>Manage changed SSH keys</BodyText>}
loading={isLoading}
>
<FlexBox spacing=".2em"> <FlexBox spacing=".2em">
<BodyText> <BodyText>
The identity of the following targets have unexpectedly changed. The identity of the following targets have unexpectedly changed.

@ -36,10 +36,7 @@ const ManageUsersForm: FC = () => {
}, [setListMessage, setUsers, users]); }, [setListMessage, setUsers, users]);
return ( return (
<ExpandablePanel <ExpandablePanel header="Manage users" loading={!users}>
header={<BodyText>Manage users</BodyText>}
loading={!users}
>
<List <List
allowEdit={false} allowEdit={false}
listEmpty={<MessageBox {...listMessage} />} listEmpty={<MessageBox {...listMessage} />}

@ -0,0 +1,56 @@
import { Switch, SxProps, Theme } from '@mui/material';
import { FC, useMemo } from 'react';
import { GREY } from '../lib/consts/DEFAULT_THEME';
import FlexBox from './FlexBox';
import { BodyText } from './Text';
const SwitchWithLabel: FC<SwitchWithLabelProps> = ({
checked: isChecked,
flexBoxProps: { sx: flexBoxSx, ...restFlexBoxProps } = {},
id: switchId,
label,
name: switchName,
onChange,
switchProps,
}) => {
const combinedFlexBoxSx = useMemo<SxProps<Theme>>(
() => ({
'& > :first-child': {
flexGrow: 1,
},
...flexBoxSx,
}),
[flexBoxSx],
);
const labelElement = useMemo(
() =>
typeof label === 'string' ? (
<BodyText inheritColour color={`${GREY}9F`}>
{label}
</BodyText>
) : (
label
),
[label],
);
return (
<FlexBox row {...restFlexBoxProps} sx={combinedFlexBoxSx}>
{labelElement}
<Switch
checked={isChecked}
edge="end"
id={switchId}
name={switchName}
onChange={onChange}
{...switchProps}
/>
</FlexBox>
);
};
export default SwitchWithLabel;

@ -1,21 +1,29 @@
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import { ReactElement, useMemo } from 'react'; import { ReactElement, ReactNode, useMemo } from 'react';
const TabContent = <T,>({ const TabContent = <T,>({
changingTabId, changingTabId,
children, children,
retain = false,
tabId, tabId,
}: TabContentProps<T>): ReactElement => { }: TabContentProps<T>): ReactElement => {
const isTabIdMatch = useMemo( const isTabIdMatch = useMemo(
() => changingTabId === tabId, () => changingTabId === tabId,
[changingTabId, tabId], [changingTabId, tabId],
); );
const displayValue = useMemo( const result = useMemo<ReactNode>(
() => (isTabIdMatch ? 'initial' : 'none'), () =>
[isTabIdMatch], retain ? (
<Box sx={{ display: isTabIdMatch ? 'initial' : 'none' }}>
{children}
</Box>
) : (
isTabIdMatch && children
),
[children, isTabIdMatch, retain],
); );
return <Box sx={{ display: displayValue }}>{children}</Box>; return <>{result}</>;
}; };
export default TabContent; export default TabContent;

@ -8,6 +8,7 @@ import { BLACK, TEXT, UNSELECTED } from '../../lib/consts/DEFAULT_THEME';
type BodyTextOptionalProps = { type BodyTextOptionalProps = {
inheritColour?: boolean; inheritColour?: boolean;
inline?: boolean;
inverted?: boolean; inverted?: boolean;
monospaced?: boolean; monospaced?: boolean;
selected?: boolean; selected?: boolean;
@ -20,6 +21,7 @@ const BODY_TEXT_CLASS_PREFIX = 'BodyText';
const BODY_TEXT_DEFAULT_PROPS: Required<BodyTextOptionalProps> = { const BODY_TEXT_DEFAULT_PROPS: Required<BodyTextOptionalProps> = {
inheritColour: false, inheritColour: false,
inline: false,
inverted: false, inverted: false,
monospaced: false, monospaced: false,
selected: true, selected: true,
@ -68,6 +70,7 @@ const BodyText: FC<BodyTextProps> = ({
children, children,
className, className,
inheritColour: isInheritColour = BODY_TEXT_DEFAULT_PROPS.inheritColour, inheritColour: isInheritColour = BODY_TEXT_DEFAULT_PROPS.inheritColour,
inline: isInline = BODY_TEXT_DEFAULT_PROPS.inline,
inverted: isInvert = BODY_TEXT_DEFAULT_PROPS.inverted, inverted: isInvert = BODY_TEXT_DEFAULT_PROPS.inverted,
monospaced: isMonospace = BODY_TEXT_DEFAULT_PROPS.monospaced, monospaced: isMonospace = BODY_TEXT_DEFAULT_PROPS.monospaced,
selected: isSelect = BODY_TEXT_DEFAULT_PROPS.selected, selected: isSelect = BODY_TEXT_DEFAULT_PROPS.selected,
@ -75,6 +78,11 @@ const BodyText: FC<BodyTextProps> = ({
text = BODY_TEXT_DEFAULT_PROPS.text, text = BODY_TEXT_DEFAULT_PROPS.text,
...muiTypographyRestProps ...muiTypographyRestProps
}) => { }) => {
const sxDisplay = useMemo<string | undefined>(
() => (isInline ? 'inline' : undefined),
[isInline],
);
const baseClassName = useMemo( const baseClassName = useMemo(
() => () =>
buildBodyTextClasses({ buildBodyTextClasses({
@ -89,11 +97,12 @@ const BodyText: FC<BodyTextProps> = ({
return ( return (
<MUITypography <MUITypography
{...{ className={`${baseClassName} ${className}`}
className: `${baseClassName} ${className}`, variant="subtitle1"
variant: 'subtitle1', {...muiTypographyRestProps}
...muiTypographyRestProps, sx={{
sx: { display: sxDisplay,
[`&.${BODY_TEXT_CLASSES.inverted}`]: { [`&.${BODY_TEXT_CLASSES.inverted}`]: {
color: BLACK, color: BLACK,
}, },
@ -112,7 +121,6 @@ const BodyText: FC<BodyTextProps> = ({
}, },
...sx, ...sx,
},
}} }}
> >
{content} {content}

@ -3,12 +3,7 @@ import { FC } from 'react';
import { BodyTextProps } from './BodyText'; import { BodyTextProps } from './BodyText';
import SmallText from './SmallText'; import SmallText from './SmallText';
type InlineMonoTextProps = BodyTextProps; const InlineMonoText: FC<BodyTextProps> = ({ sx, ...bodyTextRestProps }) => (
const InlineMonoText: FC<InlineMonoTextProps> = ({
sx,
...bodyTextRestProps
}) => (
<SmallText <SmallText
{...{ {...{
...bodyTextRestProps, ...bodyTextRestProps,
@ -23,6 +18,4 @@ const InlineMonoText: FC<InlineMonoTextProps> = ({
/> />
); );
export type { InlineMonoTextProps };
export default InlineMonoText; export default InlineMonoText;

@ -3,9 +3,7 @@ import { FC } from 'react';
import { BodyTextProps } from './BodyText'; import { BodyTextProps } from './BodyText';
import SmallText from './SmallText'; import SmallText from './SmallText';
type MonoTextProps = BodyTextProps; const MonoText: FC<BodyTextProps> = ({ sx, ...bodyTextRestProps }) => (
const MonoText: FC<MonoTextProps> = ({ sx, ...bodyTextRestProps }) => (
<SmallText <SmallText
monospaced monospaced
sx={{ alignItems: 'center', display: 'flex', height: '100%', ...sx }} sx={{ alignItems: 'center', display: 'flex', height: '100%', ...sx }}
@ -13,6 +11,4 @@ const MonoText: FC<MonoTextProps> = ({ sx, ...bodyTextRestProps }) => (
/> />
); );
export type { MonoTextProps };
export default MonoText; export default MonoText;

@ -0,0 +1,113 @@
import { Button, styled } from '@mui/material';
import {
createElement,
FC,
ReactElement,
ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import { BORDER_RADIUS, EERIE_BLACK } from '../../lib/consts/DEFAULT_THEME';
import BodyText from './BodyText';
import FlexBox from '../FlexBox';
import IconButton from '../IconButton';
import MonoText from './MonoText';
const InlineButton = styled(Button)({
backgroundColor: EERIE_BLACK,
borderRadius: BORDER_RADIUS,
minWidth: 'initial',
padding: '0 .6em',
textTransform: 'none',
':hover': {
backgroundColor: `${EERIE_BLACK}F0`,
},
});
const SensitiveText: FC<SensitiveTextProps> = ({
children,
inline: isInline = false,
monospaced: isMonospaced = false,
revealInitially: isRevealInitially = false,
textProps,
}) => {
const [isReveal, setIsReveal] = useState<boolean>(isRevealInitially);
const clickEventHandler = useCallback(() => {
setIsReveal((previous) => !previous);
}, []);
const textSxLineHeight = useMemo<number | string | undefined>(
() => (isInline ? undefined : 2.8),
[isInline],
);
const textElementType = useMemo(
() => (isMonospaced ? MonoText : BodyText),
[isMonospaced],
);
const contentElement = useMemo(() => {
let content: ReactNode;
if (isReveal) {
content =
typeof children === 'string'
? createElement(
textElementType,
{
sx: {
lineHeight: textSxLineHeight,
maxWidth: '20em',
overflowY: 'scroll',
whiteSpace: 'nowrap',
},
...textProps,
},
children,
)
: children;
} else {
content = createElement(
textElementType,
{
sx: {
lineHeight: textSxLineHeight,
},
...textProps,
},
'*****',
);
}
return content;
}, [children, isReveal, textElementType, textProps, textSxLineHeight]);
const rootElement = useMemo<ReactElement>(
() =>
isInline ? (
<InlineButton onClick={clickEventHandler}>
{contentElement}
</InlineButton>
) : (
<FlexBox row spacing=".5em">
{contentElement}
<IconButton
edge="end"
mapPreset="visibility"
onClick={clickEventHandler}
state={String(isReveal)}
sx={{ marginRight: '-.2em', padding: '.2em' }}
variant="normal"
/>
</FlexBox>
),
[clickEventHandler, contentElement, isInline, isReveal],
);
return rootElement;
};
export default SensitiveText;

@ -2,12 +2,8 @@ import { FC } from 'react';
import BodyText, { BodyTextProps } from './BodyText'; import BodyText, { BodyTextProps } from './BodyText';
type SmallTextProps = BodyTextProps; const SmallText: FC<BodyTextProps> = ({ ...bodyTextRestProps }) => (
const SmallText: FC<SmallTextProps> = ({ ...bodyTextRestProps }) => (
<BodyText {...{ variant: 'body2', ...bodyTextRestProps }} /> <BodyText {...{ variant: 'body2', ...bodyTextRestProps }} />
); );
export type { SmallTextProps };
export default SmallText; export default SmallText;

@ -2,8 +2,16 @@ import BodyText, { BodyTextProps } from './BodyText';
import HeaderText from './HeaderText'; import HeaderText from './HeaderText';
import InlineMonoText from './InlineMonoText'; import InlineMonoText from './InlineMonoText';
import MonoText from './MonoText'; import MonoText from './MonoText';
import SensitiveText from './SensitiveText';
import SmallText from './SmallText'; import SmallText from './SmallText';
export type { BodyTextProps }; export type { BodyTextProps };
export { BodyText, HeaderText, InlineMonoText, MonoText, SmallText }; export {
BodyText,
HeaderText,
InlineMonoText,
MonoText,
SensitiveText,
SmallText,
};

@ -13,6 +13,7 @@ export const DIVIDER = '#888';
export const SELECTED_ANVIL = '#00ff00'; export const SELECTED_ANVIL = '#00ff00';
export const DISABLED = '#AAA'; export const DISABLED = '#AAA';
export const BLACK = '#343434'; export const BLACK = '#343434';
export const EERIE_BLACK = '#1F1F1F';
// TODO: remove when old icons are completely replaced. // TODO: remove when old icons are completely replaced.
export const OLD_ICON = '#9da2a7'; export const OLD_ICON = '#9da2a7';

@ -1,4 +1,4 @@
import { InputProps as MUIInputProps } from '@mui/material'; import { ChangeEventHandler } from 'react';
import MAP_TO_VALUE_CONVERTER from './consts/MAP_TO_VALUE_CONVERTER'; import MAP_TO_VALUE_CONVERTER from './consts/MAP_TO_VALUE_CONVERTER';
@ -8,10 +8,11 @@ const createInputOnChangeHandler =
preSet, preSet,
set, set,
setType = 'string' as TypeName, setType = 'string' as TypeName,
}: CreateInputOnChangeHandlerOptions<TypeName> = {}): MUIInputProps['onChange'] => valueKey = 'value',
}: CreateInputOnChangeHandlerOptions<TypeName> = {}): ChangeEventHandler<HTMLInputElement> =>
(event) => { (event) => {
const { const {
target: { value }, target: { [valueKey]: value },
} = event; } = event;
const postConvertValue = MAP_TO_VALUE_CONVERTER[setType]( const postConvertValue = MAP_TO_VALUE_CONVERTER[setType](
value, value,

@ -7,6 +7,7 @@ import getQueryParam from '../../lib/getQueryParam';
import Grid from '../../components/Grid'; 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 { 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';
@ -21,9 +22,9 @@ import useProtectedState from '../../hooks/useProtectedState';
const MAP_TO_PAGE_TITLE: Record<string, string> = { const MAP_TO_PAGE_TITLE: Record<string, string> = {
'prepare-host': 'Prepare Host', 'prepare-host': 'Prepare Host',
'prepare-network': 'Prepare Network', 'prepare-network': 'Prepare Network',
'manage-fence-devices': 'Manage Fence Devices', 'manage-fence': 'Manage Fence Devices',
'manage-upses': 'Manage UPSes', 'manage-ups': 'Manage UPSes',
'manage-manifests': 'Manage Manifests', 'manage-manifest': 'Manage Manifests',
}; };
const PAGE_TITLE_LOADING = 'Loading'; const PAGE_TITLE_LOADING = 'Loading';
const STEP_CONTENT_GRID_COLUMNS = { md: 8, sm: 6, xs: 1 }; const STEP_CONTENT_GRID_COLUMNS = { md: 8, sm: 6, xs: 1 };
@ -116,6 +117,19 @@ const PrepareNetworkTabContent: FC = () => {
); );
}; };
const ManageFenceTabContent: FC = () => (
<Grid
columns={STEP_CONTENT_GRID_COLUMNS}
layout={{
'managefence-left-column': {},
'managefence-center-column': {
children: <ManageFencePanel />,
...STEP_CONTENT_GRID_CENTER_COLUMN,
},
}}
/>
);
const ManageElement: FC = () => { const ManageElement: FC = () => {
const { const {
isReady, isReady,
@ -162,7 +176,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-devices" /> <Tab label="Manage fence devices" value="manage-fence" />
</Tabs> </Tabs>
</Panel> </Panel>
<TabContent changingTabId={pageTabId} tabId="prepare-host"> <TabContent changingTabId={pageTabId} tabId="prepare-host">
@ -171,8 +185,8 @@ const ManageElement: FC = () => {
<TabContent changingTabId={pageTabId} tabId="prepare-network"> <TabContent changingTabId={pageTabId} tabId="prepare-network">
<PrepareNetworkTabContent /> <PrepareNetworkTabContent />
</TabContent> </TabContent>
<TabContent changingTabId={pageTabId} tabId="manage-fence-devices"> <TabContent changingTabId={pageTabId} tabId="manage-fence">
{} <ManageFenceTabContent />
</TabContent> </TabContent>
</> </>
); );

@ -0,0 +1,43 @@
type FenceParameterType =
| 'boolean'
| 'integer'
| 'second'
| 'select'
| 'string';
type FenceParameters = {
[parameterId: string]: string;
};
type APIFenceOverview = {
[fenceUUID: string]: {
fenceAgent: string;
fenceParameters: FenceParameters;
fenceName: string;
fenceUUID: string;
};
};
type APIFenceTemplate = {
[fenceId: string]: {
actions: string[];
description: string;
parameters: {
[parameterId: string]: {
content_type: FenceParameterType;
default?: string;
deprecated: number;
description: string;
obsoletes: number;
options?: string[];
replacement: string;
required: '0' | '1';
switches: string;
unique: '0' | '1';
};
};
switch: {
[switchId: string]: { name: string };
};
};
};

@ -0,0 +1,12 @@
type FenceAutocompleteOption = {
fenceDescription: string;
fenceId: string;
label: string;
};
type AddFenceInputGroupOptionalProps = {
fenceTemplate?: APIFenceTemplate;
loading?: boolean;
};
type AddFenceInputGroupProps = AddFenceInputGroupOptionalProps;

@ -0,0 +1,28 @@
type FenceParameterInputBuilderParameters = {
id: string;
isChecked?: boolean;
isRequired?: boolean;
isSensitive?: boolean;
label?: string;
name?: string;
selectOptions?: string[];
value?: string;
};
type FenceParameterInputBuilder = (
args: FenceParameterInputBuilderParameters,
) => ReactElement;
type MapToInputBuilder = Partial<
Record<Exclude<FenceParameterType, 'string'>, FenceParameterInputBuilder>
> & { string: FenceParameterInputBuilder };
type CommonFenceInputGroupOptionalProps = {
fenceId?: string;
fenceTemplate?: APIFenceTemplate;
previousFenceName?: string;
previousFenceParameters?: FenceParameters;
fenceParameterTooltipProps?: import('@mui/material').TooltipProps;
};
type CommonFenceInputGroupProps = CommonFenceInputGroupOptionalProps;

@ -1,20 +1,25 @@
type ConfirmDialogOptionalProps = { type ConfirmDialogOptionalProps = {
actionCancelText?: string; actionCancelText?: string;
closeOnProceed?: boolean; closeOnProceed?: boolean;
contentContainerProps?: import('../components/FlexBox').FlexBoxProps;
dialogProps?: Partial<import('@mui/material').DialogProps>; dialogProps?: Partial<import('@mui/material').DialogProps>;
formContent?: boolean;
loadingAction?: boolean; loadingAction?: boolean;
onActionAppend?: ContainedButtonProps['onClick']; onActionAppend?: ContainedButtonProps['onClick'];
onProceedAppend?: ContainedButtonProps['onClick']; onProceedAppend?: ContainedButtonProps['onClick'];
onCancelAppend?: ContainedButtonProps['onClick']; onCancelAppend?: ContainedButtonProps['onClick'];
onSubmitAppend?: import('react').FormEventHandler<HTMLDivElement>;
openInitially?: boolean; openInitially?: boolean;
proceedButtonProps?: ContainedButtonProps; proceedButtonProps?: ContainedButtonProps;
proceedColour?: 'blue' | 'red'; proceedColour?: 'blue' | 'red';
scrollContent?: boolean;
scrollBoxProps?: import('@mui/material').BoxProps;
}; };
type ConfirmDialogProps = ConfirmDialogOptionalProps & { type ConfirmDialogProps = ConfirmDialogOptionalProps & {
actionProceedText: string; actionProceedText: string;
content: import('@mui/material').ReactNode; content: import('react').ReactNode;
titleText: string; titleText: import('react').ReactNode;
}; };
type ConfirmDialogForwardedRefContent = { type ConfirmDialogForwardedRefContent = {

@ -1,4 +1,4 @@
type MapToInputType = Pick<MapToType, 'number' | 'string'>; type MapToInputType = Pick<MapToType, 'boolean' | 'number' | 'string'>;
type InputOnChangeParameters = Parameters< type InputOnChangeParameters = Parameters<
Exclude<import('@mui/material').InputBaseProps['onChange'], undefined> Exclude<import('@mui/material').InputBaseProps['onChange'], undefined>
@ -12,4 +12,8 @@ type CreateInputOnChangeHandlerOptions<TypeName extends keyof MapToInputType> =
preSet?: (...args: InputOnChangeParameters) => void; preSet?: (...args: InputOnChangeParameters) => void;
set?: StateSetter; set?: StateSetter;
setType?: TypeName; setType?: TypeName;
valueKey?: Extract<
keyof import('react').ChangeEvent<HTMLInputElement>['target'],
'checked' | 'value'
>;
}; };

@ -0,0 +1,12 @@
type EditFenceInputGroupOptionalProps = {
fenceTemplate?: APIFenceTemplate;
loading?: boolean;
};
type EditFenceInputGroupProps = EditFenceInputGroupOptionalProps &
Required<
Pick<
CommonFenceInputGroupProps,
'fenceId' | 'previousFenceName' | 'previousFenceParameters'
>
>;

@ -0,0 +1,10 @@
type ExpandablePanelOptionalProps = {
expandInitially?: boolean;
loading?: boolean;
panelProps?: InnerPanelProps;
showHeaderSpinner?: boolean;
};
type ExpandablePanelProps = ExpandablePanelOptionalProps & {
header: import('react').ReactNode;
};

@ -0,0 +1,16 @@
type CreatableComponent = Parameters<typeof import('react').createElement>[0];
type IconButtonPresetMapToStateIcon = 'edit' | 'visibility';
type IconButtonMapToStateIcon = Record<string, CreatableComponent>;
type IconButtonVariant = 'contained' | 'normal';
type IconButtonOptionalProps = {
defaultIcon?: CreatableComponent;
iconProps?: import('@mui/material').SvgIconProps;
mapPreset?: IconButtonPresetMapToStateIcon;
mapToIcon?: IconButtonMapToStateIcon;
state?: string;
variant?: IconButtonVariant;
};

@ -0,0 +1 @@
type InnerPanelProps = import('@mui/material').BoxProps;

@ -1,12 +1,18 @@
type OnCheckboxChange = Exclude<CheckboxProps['onChange'], undefined>; type CheckboxChangeEventHandler = Exclude<CheckboxProps['onChange'], undefined>;
type ListItemButtonChangeEventHandler = Exclude<
import('@mui/material').ListItemButtonProps['onClick'],
undefined
>;
type ListOptionalProps<T extends unknown = unknown> = { type ListOptionalProps<T extends unknown = unknown> = {
allowCheckAll?: boolean;
allowAddItem?: boolean; allowAddItem?: boolean;
allowCheckAll?: boolean;
allowCheckItem?: boolean; allowCheckItem?: boolean;
allowEdit?: boolean;
allowDelete?: boolean; allowDelete?: boolean;
allowEdit?: boolean;
allowEditItem?: boolean; allowEditItem?: boolean;
allowItemButton?: boolean;
edit?: boolean; edit?: boolean;
flexBoxProps?: import('../components/FlexBox').FlexBoxProps; flexBoxProps?: import('../components/FlexBox').FlexBoxProps;
header?: import('react').ReactNode; header?: import('react').ReactNode;
@ -22,11 +28,16 @@ type ListOptionalProps<T extends unknown = unknown> = {
onAdd?: import('../components/IconButton').IconButtonProps['onClick']; onAdd?: import('../components/IconButton').IconButtonProps['onClick'];
onDelete?: import('../components/IconButton').IconButtonProps['onClick']; onDelete?: import('../components/IconButton').IconButtonProps['onClick'];
onEdit?: import('../components/IconButton').IconButtonProps['onClick']; onEdit?: import('../components/IconButton').IconButtonProps['onClick'];
onAllCheckboxChange?: OnCheckboxChange; onAllCheckboxChange?: CheckboxChangeEventHandler;
onItemCheckboxChange?: ( onItemCheckboxChange?: (
key: string, key: string,
...onChangeParams: Parameters<OnCheckboxChange> ...checkboxChangeEventHandlerArgs: Parameters<CheckboxChangeEventHandler>
) => ReturnType<OnCheckboxChange>; ) => ReturnType<CheckboxChangeEventHandler>;
onItemClick?: (
value: T,
key: string,
...listItemButtonChangeEventHandlerArgs: Parameters<ListItemButtonChangeEventHandler>
) => ReturnType<ListItemButtonChangeEventHandler>;
renderListItem?: (key: string, value: T) => import('react').ReactNode; renderListItem?: (key: string, value: T) => import('react').ReactNode;
renderListItemCheckboxState?: (key: string, value: T) => boolean; renderListItemCheckboxState?: (key: string, value: T) => boolean;
scroll?: boolean; scroll?: boolean;

@ -0,0 +1,5 @@
type SelectOptionalProps = {
onClearIndicatorClick?: import('@mui/material').IconButtonProps['onClick'];
};
type SelectProps = import('@mui/material').SelectProps & SelectOptionalProps;

@ -5,3 +5,26 @@ type SelectItem<
displayValue?: DisplayValueType; displayValue?: DisplayValueType;
value: ValueType; value: ValueType;
}; };
type OperateSelectItemFunction = (value: string) => boolean;
type SelectWithLabelOptionalProps = {
checkItem?: OperateSelectItemFunction;
disableItem?: OperateSelectItemFunction;
formControlProps?: import('@mui/material').FormControlProps;
hideItem?: OperateSelectItemFunction;
isCheckableItems?: boolean;
isReadOnly?: boolean;
inputLabelProps?: Partial<
import('../components/OutlinedInputLabel').OutlinedInputLabelProps
>;
label?: string;
messageBoxProps?: Partial<import('../components/MessageBox').MessageBoxProps>;
selectProps?: Partial<SelectProps>;
};
type SelectWithLabelProps = SelectWithLabelOptionalProps &
Pick<SelectProps, 'name' | 'onChange' | 'value'> & {
id: string;
selectItems: Array<SelectItem | string>;
};

@ -0,0 +1,8 @@
type SensitiveTextOptionalProps = {
inline?: boolean;
monospaced?: boolean;
revealInitially?: boolean;
textProps?: import('../components/Text').BodyTextProps;
};
type SensitiveTextProps = SensitiveTextOptionalProps;

@ -0,0 +1,12 @@
type SwitchWithLabelOptionalProps = {
flexBoxProps?: import('../components/FlexBox').FlexBoxProps;
switchProps?: import('@mui/material').SwitchProps;
};
type SwitchWithLabelProps = SwitchWithLabelOptionalProps &
Pick<
import('@mui/material').SwitchProps,
'checked' | 'id' | 'name' | 'onChange'
> & {
label: import('react').ReactNode;
};

@ -1,4 +1,9 @@
type TabContentProps<T> = import('react').PropsWithChildren<{ type TabContentOptionalProps = {
retain?: boolean;
};
type TabContentProps<T> = TabContentOptionalProps &
import('react').PropsWithChildren<{
changingTabId: T; changingTabId: T;
tabId: T; tabId: T;
}>; }>;

Loading…
Cancel
Save