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