ui: Enable mixed-state behavior for master checkbox in AdavncedMultiSelect

The AdvancedMultiSelect should adhere to some set of human interface
guidelines. In the absence of a formal, agreed upon set of guidelines
for Infection Monkey, this commit uses KDE's guidelines for checkboxes:
https://hig.kde.org/components/editing/checkbox.html

When child checkboxes are not all checked, the master checkbox displays
a mixed-state icon, instead of a checked icon. Clicking the mixed-state
icon checks all child checkboxes. Clicking an unchecked master checkbox
also enables all child checkboxes.

In the past, clicking an unchecked master checkbox checked only the
*default* child checkboxes. While this may seem desirable so that unsafe
exploits do not accidentally get selected by the user, it will confuse
and frustrate users, as master/child checkboxes do not normally function
this way. If there is concern that users may unknowingly select unsafe
exploits/options, we should pop up a warning to inform the user when the
config is saved/submitted.

Issue #891
This commit is contained in:
Mike Salvatore 2021-01-11 20:24:50 -05:00
parent 878f959a8f
commit 19bc09196f
1 changed files with 44 additions and 16 deletions

View File

@ -2,6 +2,7 @@ import React from "react";
import {Card, Button, Form} from 'react-bootstrap'; import {Card, Button, Form} from 'react-bootstrap';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faCheckSquare} from '@fortawesome/free-solid-svg-icons'; import {faCheckSquare} from '@fortawesome/free-solid-svg-icons';
import {faMinusSquare} from '@fortawesome/free-solid-svg-icons';
import {faSquare} from '@fortawesome/free-regular-svg-icons'; import {faSquare} from '@fortawesome/free-regular-svg-icons';
import {cloneDeep} from 'lodash'; import {cloneDeep} from 'lodash';
@ -9,6 +10,12 @@ import {getComponentHeight} from './utils/HeightCalculator';
import {resolveObjectPath} from './utils/ObjectPathResolver'; import {resolveObjectPath} from './utils/ObjectPathResolver';
import InfoPane from './InfoPane'; import InfoPane from './InfoPane';
const MasterCheckboxState = {
NONE: 0,
MIXED: 1,
ALL: 2
}
// Definitions passed to components only contains value and label, // Definitions passed to components only contains value and label,
// custom fields like "info" or "links" must be pulled from registry object using this function // custom fields like "info" or "links" must be pulled from registry object using this function
@ -40,12 +47,19 @@ function MasterCheckbox(props) {
checkboxState checkboxState
} = props; } = props;
var newCheckboxIcon = faCheckSquare;
if (checkboxState == MasterCheckboxState.NONE)
newCheckboxIcon = faSquare;
else if (checkboxState == MasterCheckboxState.MIXED)
newCheckboxIcon = faMinusSquare;
return ( return (
<Card.Header> <Card.Header>
<Button key={`${title}-button`} value={value} <Button key={`${title}-button`} value={value}
variant={'link'} disabled={disabled} variant={'link'} disabled={disabled}
onClick={onClick}> onClick={onClick}>
<FontAwesomeIcon icon={checkboxState ? faCheckSquare : faSquare}/> <FontAwesomeIcon icon={newCheckboxIcon}/>
</Button> </Button>
<span className={'header-title'}>{title}</span> <span className={'header-title'}>{title}</span>
</Card.Header> </Card.Header>
@ -77,42 +91,57 @@ function ChildCheckbox(props) {
class AdvancedMultiSelect extends React.Component { class AdvancedMultiSelect extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = {masterCheckbox: true, infoPaneParams: getDefaultPaneParams(props.schema.items.$ref, props.registry)}; this.state = {
masterCheckboxState: this.getMasterCheckboxState(props.value),
infoPaneParams: getDefaultPaneParams(props.schema.items.$ref, props.registry)
};
this.onMasterCheckboxClick = this.onMasterCheckboxClick.bind(this); this.onMasterCheckboxClick = this.onMasterCheckboxClick.bind(this);
this.onChildCheckboxClick = this.onChildCheckboxClick.bind(this); this.onChildCheckboxClick = this.onChildCheckboxClick.bind(this);
this.setPaneInfo = this.setPaneInfo.bind(this, props.schema.items.$ref, props.registry); this.setPaneInfo = this.setPaneInfo.bind(this, props.schema.items.$ref, props.registry);
} }
onMasterCheckboxClick() { onMasterCheckboxClick() {
if (this.state.masterCheckbox) { var newValues = this.props.options.enumOptions.map(({value}) => value);
this.props.onChange([]);
} else { if (this.state.masterCheckboxState == MasterCheckboxState.ALL) {
this.props.onChange(this.props.schema.default); newValues = [];
} }
this.toggleMasterCheckbox(); this.props.onChange(newValues);
this.setMasterCheckboxState(newValues);
} }
onChildCheckboxClick(value) { onChildCheckboxClick(value) {
this.props.onChange(this.getSelectValuesAfterClick(value)); var selectValues = this.getSelectValuesAfterClick(value)
this.props.onChange(selectValues);
this.setMasterCheckboxState(selectValues);
} }
getSelectValuesAfterClick(clickedValue) { getSelectValuesAfterClick(clickedValue) {
const valueArray = cloneDeep(this.props.value); const valueArray = cloneDeep(this.props.value);
if (valueArray.includes(clickedValue)) { if (valueArray.includes(clickedValue)) {
return valueArray.filter((e) => { return valueArray.filter(e => e !== clickedValue);
return e !== clickedValue;
});
} else { } else {
valueArray.push(clickedValue); valueArray.push(clickedValue);
return valueArray; return valueArray;
} }
} }
toggleMasterCheckbox() { getMasterCheckboxState(selectValues) {
this.setState((state) => ({ if (selectValues.length == 0)
masterCheckbox: !state.masterCheckbox return MasterCheckboxState.NONE;
if (selectValues.length != this.props.options.enumOptions.length)
return MasterCheckboxState.MIXED;
return MasterCheckboxState.ALL;
}
setMasterCheckboxState(selectValues) {
this.setState(() => ({
masterCheckboxState: this.getMasterCheckboxState(selectValues)
})); }));
} }
@ -132,7 +161,6 @@ class AdvancedMultiSelect extends React.Component {
readonly, readonly,
multiple, multiple,
autofocus, autofocus,
onChange,
registry registry
} = this.props; } = this.props;
@ -143,7 +171,7 @@ class AdvancedMultiSelect extends React.Component {
<div className={'advanced-multi-select'}> <div className={'advanced-multi-select'}>
<MasterCheckbox title={schema.title} value={value} <MasterCheckbox title={schema.title} value={value}
disabled={disabled} onClick={this.onMasterCheckboxClick} disabled={disabled} onClick={this.onMasterCheckboxClick}
checkboxState={this.state.masterCheckbox}/> checkboxState={this.state.masterCheckboxState}/>
<Form.Group <Form.Group
style={{height: `${getComponentHeight(enumOptions.length)}px`}} style={{height: `${getComponentHeight(enumOptions.length)}px`}}
id={id} id={id}