+ {showDropdown && (
+
{this.state.allNodesHidden ? (
{this.props.noMatchesText || 'No matches found'}
) : (
@@ -239,9 +241,11 @@ class DropdownTreeSelect extends Component {
onAction={this.onAction}
onCheckboxChange={this.onCheckboxChange}
onNodeToggle={this.onNodeToggle}
- simpleSelect={this.props.simpleSelect}
+ simpleSelect={simpleSelect}
+ radioSelect={radioSelect}
showPartiallySelected={this.props.showPartiallySelected}
- readOnly={this.props.readOnly}
+ readOnly={readOnly}
+ clientId={this.clientId}
/>
)}
diff --git a/src/index.test.js b/src/index.test.js
index 87ee8248..3e86b94d 100644
--- a/src/index.test.js
+++ b/src/index.test.js
@@ -60,6 +60,12 @@ test('renders default state', t => {
t.snapshot(toJson(wrapper))
})
+test('renders default radio select state', t => {
+ const { tree } = t.context
+ const wrapper = mount(
)
+ t.snapshot(toJson(wrapper))
+})
+
test('shows dropdown', t => {
const { tree } = t.context
const wrapper = shallow(
)
@@ -107,13 +113,20 @@ test('sets search mode on input change', t => {
test('hides dropdown onChange for simpleSelect', t => {
const { tree } = t.context
- const wrapper = mount(
)
+ const wrapper = mount(
)
wrapper.instance().onCheckboxChange(node0._id, true)
t.false(wrapper.state().searchModeOn)
t.false(wrapper.state().allNodesHidden)
t.false(wrapper.state().showDropdown)
})
+test('keeps dropdown open onChange for simpleSelect and keepOpenOnSelect', t => {
+ const { tree } = t.context
+ const wrapper = mount(
)
+ wrapper.instance().onCheckboxChange(node0._id, true)
+ t.true(wrapper.state().showDropdown)
+})
+
test('clears input onChange for clearSearchOnChange', t => {
const { tree } = t.context
const wrapper = mount(
)
diff --git a/src/radio/index.js b/src/radio/index.js
new file mode 100644
index 00000000..e150fcef
--- /dev/null
+++ b/src/radio/index.js
@@ -0,0 +1,37 @@
+import React, { PureComponent } from 'react'
+import PropTypes from 'prop-types'
+
+export const refUpdater = ({ checked }) => input => {
+ if (input) {
+ input.checked = checked
+ }
+}
+
+class RadioButton extends PureComponent {
+ static propTypes = {
+ name: PropTypes.string.isRequired,
+ checked: PropTypes.bool,
+ onChange: PropTypes.func,
+ disabled: PropTypes.bool,
+ readOnly: PropTypes.bool,
+ }
+
+ render() {
+ const { name, checked, onChange, disabled, readOnly, ...rest } = this.props
+
+ const isDisabled = disabled || readOnly
+
+ return (
+
+ )
+ }
+}
+
+export default RadioButton
diff --git a/src/radio/index.test.js b/src/radio/index.test.js
new file mode 100644
index 00000000..152052e1
--- /dev/null
+++ b/src/radio/index.test.js
@@ -0,0 +1,22 @@
+import { shallow } from 'enzyme'
+import React from 'react'
+import test from 'ava'
+import toJson from 'enzyme-to-json'
+
+import RadioButton, { refUpdater } from './index'
+
+test('Radio component', t => {
+ const input = toJson(shallow(
))
+ t.snapshot(input)
+})
+
+test('renders checked state', t => {
+ const input = {}
+ refUpdater({ checked: true })(input)
+ t.true(input.checked)
+})
+
+test('renders disabled state', t => {
+ const input = toJson(shallow(
))
+ t.snapshot(input)
+})
diff --git a/src/tag/index.css b/src/tag/index.css
index 2ca1d4e9..98395e88 100644
--- a/src/tag/index.css
+++ b/src/tag/index.css
@@ -15,7 +15,8 @@
border: none;
outline: none;
- &.readOnly {
+ &.readOnly,
+ &.disabled {
cursor: not-allowed;
}
}
diff --git a/src/tree-manager/flatten-tree.js b/src/tree-manager/flatten-tree.js
index 68518a3e..de4f840d 100644
--- a/src/tree-manager/flatten-tree.js
+++ b/src/tree-manager/flatten-tree.js
@@ -92,33 +92,35 @@ const tree = [
}
```
* @param {[type]} tree The incoming tree object
- * @param {[bool]} simple Whether its in Single slect mode (simple dropdown)
+ * @param {[bool]} simple Whether its in Single select mode (simple dropdown)
+ * @param {[bool]} radio Whether its in Radio select mode (radio dropdown)
* @param {[bool]} showPartialState Whether to show partially checked state
* @param {[string]} rootPrefixId The prefix to use when setting root node ids
* @return {object} The flattened list
*/
-function flattenTree({ tree, simple, showPartialState, hierarchical, rootPrefixId }) {
+function flattenTree({ tree, simple, radio, showPartialState, hierarchical, rootPrefixId }) {
const forest = Array.isArray(tree) ? tree : [tree]
// eslint-disable-next-line no-use-before-define
- const { list, defaultValues } = walkNodes({
+ return walkNodes({
nodes: forest,
simple,
+ radio,
showPartialState,
hierarchical,
rootPrefixId,
})
- return { list, defaultValues }
}
/**
* If the node didn't specify anything on its own
* figure out the initial state based on parent
- * @param {object} node [current node]
- * @param {object} parent [node's immediate parent]
+ * @param {object} node [current node]
+ * @param {object} parent [node's immediate parent]
+ * @param {bool} inheritChecked [if checked should be inherited]
*/
-function setInitialStateProps(node, parent = {}) {
- const stateProps = ['checked', 'disabled']
+function setInitialStateProps(node, parent = {}, inheritChecked = true) {
+ const stateProps = inheritChecked ? ['checked', 'disabled'] : ['disabled']
for (let index = 0; index < stateProps.length; index++) {
const prop = stateProps[index]
@@ -131,15 +133,16 @@ function setInitialStateProps(node, parent = {}) {
function walkNodes({
nodes,
- list = new Map(),
parent,
depth = 0,
simple,
+ radio,
showPartialState,
- defaultValues = [],
hierarchical,
rootPrefixId,
+ _rv = { list: new Map(), defaultValues: [], singleSelectedNode: null },
}) {
+ const single = simple || radio
nodes.forEach((node, i) => {
node._depth = depth
@@ -151,31 +154,48 @@ function walkNodes({
node._id = node.id || `${rootPrefixId ? `${rootPrefixId}-${i}` : i}`
}
- if (node.isDefaultValue) {
- defaultValues.push(node._id)
+ if (single && node.checked) {
+ if (_rv.singleSelectedNode) {
+ node.checked = false
+ } else {
+ _rv.singleSelectedNode = node
+ }
+ }
+
+ if (single && node.isDefaultValue && _rv.singleSelectedNode && !_rv.singleSelectedNode.isDefaultValue) {
+ // Default value has precedence, uncheck previous value
+ _rv.singleSelectedNode.checked = false
+ _rv.singleSelectedNode = null
+ }
+
+ if (node.isDefaultValue && (!single || _rv.defaultValues.length === 0)) {
+ _rv.defaultValues.push(node._id)
node.checked = true
+ if (single) {
+ _rv.singleSelectedNode = node
+ }
}
- if (!hierarchical) setInitialStateProps(node, parent)
+ if (!hierarchical || radio) setInitialStateProps(node, parent, !radio)
- list.set(node._id, node)
+ _rv.list.set(node._id, node)
if (!simple && node.children) {
node._children = []
walkNodes({
nodes: node.children,
- list,
parent: node,
depth: depth + 1,
+ radio,
showPartialState,
- defaultValues,
hierarchical,
+ _rv,
})
if (showPartialState && !node.checked) {
node.partial = getPartialState(node)
// re-check if all children are checked. if so, check thyself
- if (!isEmpty(node.children) && node.children.every(c => c.checked)) {
+ if (!single && !isEmpty(node.children) && node.children.every(c => c.checked)) {
node.checked = true
}
}
@@ -183,7 +203,8 @@ function walkNodes({
node.children = undefined
}
})
- return { list, defaultValues }
+
+ return _rv
}
export default flattenTree
diff --git a/src/tree-manager/index.js b/src/tree-manager/index.js
index fc6c1e5f..14f99a80 100644
--- a/src/tree-manager/index.js
+++ b/src/tree-manager/index.js
@@ -4,11 +4,12 @@ import { isEmpty } from '../utils'
import flattenTree from './flatten-tree'
class TreeManager {
- constructor({ data, simpleSelect, showPartiallySelected, hierarchical, rootPrefixId }) {
+ constructor({ data, simpleSelect, radioSelect, showPartiallySelected, hierarchical, rootPrefixId }) {
this._src = data
- const { list, defaultValues } = flattenTree({
+ const { list, defaultValues, singleSelectedNode } = flattenTree({
tree: JSON.parse(JSON.stringify(data)),
simple: simpleSelect,
+ radio: radioSelect,
showPartialState: showPartiallySelected,
hierarchical,
rootPrefixId,
@@ -16,9 +17,14 @@ class TreeManager {
this.tree = list
this.defaultValues = defaultValues
this.simpleSelect = simpleSelect
+ this.radioSelect = radioSelect
this.showPartialState = !hierarchical && showPartiallySelected
this.searchMaps = new Map()
this.hierarchical = hierarchical
+ if ((simpleSelect || radioSelect) && singleSelectedNode) {
+ // Remembers initial check on single select dropdowns
+ this.currentChecked = singleSelectedNode._id
+ }
}
getNodeById(id) {
@@ -130,14 +136,14 @@ class TreeManager {
return this.tree
}
- togglePreviousChecked(id) {
+ togglePreviousChecked(id, checked) {
const prevChecked = this.currentChecked
// if id is same as previously selected node, then do nothing (since it's state is already set correctly by setNodeCheckedState)
// but if they ar not same, then toggle the previous one
if (prevChecked && prevChecked !== id) this.getNodeById(prevChecked).checked = false
- this.currentChecked = id
+ this.currentChecked = checked ? id : null
}
setNodeCheckedState(id, checked) {
@@ -150,7 +156,15 @@ class TreeManager {
}
if (this.simpleSelect) {
- this.togglePreviousChecked(id)
+ this.togglePreviousChecked(id, checked)
+ } else if (this.radioSelect) {
+ this.togglePreviousChecked(id, checked)
+ if (this.showPartialState) {
+ this.partialCheckParents(node)
+ }
+ if (!checked) {
+ this.unCheckParents(node)
+ }
} else {
if (!this.hierarchical) this.toggleChildren(id, checked)
@@ -222,6 +236,10 @@ class TreeManager {
}
getTags() {
+ if (this.radioSelect || this.simpleSelect) {
+ return this._getTagsForSingleSelect()
+ }
+
const tags = []
const visited = {}
const markSubTreeVisited = node => {
@@ -234,17 +252,27 @@ class TreeManager {
if (node.checked) {
tags.push(node)
-
- if (!this.hierarchical) {
- // Parent node, so no need to walk children
- markSubTreeVisited(node)
- }
} else {
visited[key] = true
}
+ if (node.checked && !this.hierarchical) {
+ // Parent node, so no need to walk children
+ markSubTreeVisited(node)
+ }
})
return tags
}
+
+ getTreeAndTags() {
+ return { tree: this.tree, tags: this.getTags() }
+ }
+
+ _getTagsForSingleSelect() {
+ if (this.currentChecked) {
+ return [this.getNodeById(this.currentChecked)]
+ }
+ return []
+ }
}
export default TreeManager
diff --git a/src/tree-manager/tests/radioSelect.test.js b/src/tree-manager/tests/radioSelect.test.js
new file mode 100644
index 00000000..cfdf1a86
--- /dev/null
+++ b/src/tree-manager/tests/radioSelect.test.js
@@ -0,0 +1,72 @@
+import test from 'ava'
+import React from 'react'
+import { mount } from 'enzyme'
+import TreeManager from '..'
+import DropdownTreeSelect from '../../'
+
+const dropdownId = 'rdts'
+const tree = ['nodeA', 'nodeB', 'nodeC'].map(nv => ({ id: nv, label: nv, value: nv }))
+
+test('should render radio inputs with shared name', t => {
+ const wrapper = mount(
)
+
+ const inputs = wrapper.find('.dropdown-content').find(`input[type="radio"][name="${dropdownId}"]`)
+ t.deepEqual(inputs.length, 3)
+})
+
+test('hides dropdown onChange for radioSelect', t => {
+ const wrapper = mount(
)
+ wrapper.instance().onCheckboxChange('nodeA', true)
+ t.false(wrapper.state().searchModeOn)
+ t.false(wrapper.state().allNodesHidden)
+ t.false(wrapper.state().showDropdown)
+})
+
+test('keeps dropdown open onChange for radioSelect and keepOpenOnSelect', t => {
+ const wrapper = mount(
)
+ wrapper.instance().onCheckboxChange('nodeA', true)
+ t.true(wrapper.state().showDropdown)
+})
+
+test('should deselect previous node', t => {
+ const manager = new TreeManager({ data: tree, radioSelect: true })
+
+ // first select a node
+ manager.setNodeCheckedState('nodeA', true)
+
+ // then select another node
+ manager.setNodeCheckedState('nodeB', true)
+
+ t.false(manager.getNodeById('nodeA').checked)
+ t.true(manager.getNodeById('nodeB').checked)
+})
+
+test('should only select single first checked node on init', t => {
+ const data = tree.map(n => ({ ...n, checked: true }))
+
+ const manager = new TreeManager({ data, radioSelect: true })
+
+ t.true(manager.getNodeById('nodeA').checked)
+ t.false(manager.getNodeById('nodeB').checked)
+ t.false(manager.getNodeById('nodeC').checked)
+})
+
+test('should only select single first default value node on init', t => {
+ const data = tree.map(n => ({ ...n, isDefaultValue: true }))
+
+ const manager = new TreeManager({ data, radioSelect: true })
+
+ t.true(manager.getNodeById('nodeA').checked)
+ t.falsy(manager.getNodeById('nodeB').checked)
+ t.falsy(manager.getNodeById('nodeC').checked)
+})
+
+test('should select single first default node and ignore any checked', t => {
+ const data = [{ id: 'nodeA', checked: true }, { id: 'nodeB', isDefaultValue: true }, { id: 'nodeC', checked: true }]
+
+ const manager = new TreeManager({ data, radioSelect: true })
+
+ t.false(manager.getNodeById('nodeA').checked)
+ t.true(manager.getNodeById('nodeB').checked)
+ t.false(manager.getNodeById('nodeC').checked)
+})
diff --git a/src/tree-node/index.css b/src/tree-node/index.css
index 784bf20a..4f5c7bb8 100644
--- a/src/tree-node/index.css
+++ b/src/tree-node/index.css
@@ -45,7 +45,8 @@
display: none;
}
-.checkbox-item {
+.checkbox-item,
+.radio-item {
vertical-align: middle;
margin: 0 4px 0 0;
diff --git a/src/tree-node/index.js b/src/tree-node/index.js
index 2998b572..324fce7b 100644
--- a/src/tree-node/index.js
+++ b/src/tree-node/index.js
@@ -26,6 +26,7 @@ const getNodeCx = props => {
className,
showPartiallySelected,
readOnly,
+ checked,
} = props
return cx(
@@ -39,6 +40,7 @@ const getNodeCx = props => {
'match-in-parent': keepTreeOnSearch && keepChildrenOnSearch && matchInParent,
partial: showPartiallySelected && partial,
readOnly,
+ checked,
},
className
)
@@ -66,13 +68,16 @@ class TreeNode extends PureComponent {
onAction: PropTypes.func,
onCheckboxChange: PropTypes.func,
simpleSelect: PropTypes.bool,
+ radioSelect: PropTypes.bool,
showPartiallySelected: PropTypes.bool,
readOnly: PropTypes.bool,
+ clientId: PropTypes.string,
}
render() {
const {
simpleSelect,
+ radioSelect,
keepTreeOnSearch,
_id,
_children,
@@ -92,6 +97,7 @@ class TreeNode extends PureComponent {
onCheckboxChange,
showPartiallySelected,
readOnly,
+ clientId,
} = this.props
const liCx = getNodeCx(this.props)
const style = keepTreeOnSearch || !searchModeOn ? { paddingLeft: `${(_depth || 0) * 20}px` } : {}
@@ -108,9 +114,11 @@ class TreeNode extends PureComponent {
value={value}
disabled={disabled}
simpleSelect={simpleSelect}
+ radioSelect={radioSelect}
onCheckboxChange={onCheckboxChange}
showPartiallySelected={showPartiallySelected}
readOnly={readOnly}
+ clientId={clientId}
/>
diff --git a/src/tree-node/node-label.js b/src/tree-node/node-label.js
index 90b200fd..89bac4be 100644
--- a/src/tree-node/node-label.js
+++ b/src/tree-node/node-label.js
@@ -2,6 +2,7 @@ import cn from 'classnames/bind'
import PropTypes from 'prop-types'
import React, { PureComponent } from 'react'
import Checkbox from '../checkbox'
+import RadioButton from '../radio'
import styles from './index.css'
@@ -20,15 +21,17 @@ class NodeLabel extends PureComponent {
disabled: PropTypes.bool,
dataset: PropTypes.object,
simpleSelect: PropTypes.bool,
+ radioSelect: PropTypes.bool,
showPartiallySelected: PropTypes.bool,
onCheckboxChange: PropTypes.func,
readOnly: PropTypes.bool,
+ clientId: PropTypes.string,
}
handleCheckboxChange = e => {
- const { simpleSelect, id, onCheckboxChange } = this.props
+ const { simpleSelect, radioSelect, id, onCheckboxChange } = this.props
- if (simpleSelect) {
+ if (simpleSelect || radioSelect) {
onCheckboxChange(id, true)
} else {
const {
@@ -36,34 +39,38 @@ class NodeLabel extends PureComponent {
} = e
onCheckboxChange(id, checked)
}
+ e.stopPropagation()
+ e.nativeEvent.stopImmediatePropagation()
}
render() {
- const { simpleSelect, title, label, id, partial, checked } = this.props
- const { value, disabled, showPartiallySelected, readOnly } = this.props
+ const { simpleSelect, radioSelect, title, label, id, partial, checked } = this.props
+ const { value, disabled, showPartiallySelected, readOnly, clientId } = this.props
const nodeLabelProps = { className: 'node-label' }
// in case of simple select mode, there is no checkbox, so we need to handle the click via the node label
- // but not if the control is in readOnly state
- const shouldRegisterClickHandler = simpleSelect && !readOnly
+ // but not if the control is in readOnly or disabled state
+ const shouldRegisterClickHandler = simpleSelect && !readOnly && !disabled
if (shouldRegisterClickHandler) {
nodeLabelProps.onClick = this.handleCheckboxChange
}
+ const sharedProps = { id, value, checked, disabled, readOnly }
+
return (
)
diff --git a/src/tree-node/node-label.test.js b/src/tree-node/node-label.test.js
index 4a854b8e..e5c97e40 100644
--- a/src/tree-node/node-label.test.js
+++ b/src/tree-node/node-label.test.js
@@ -1,4 +1,4 @@
-import { shallow } from 'enzyme'
+import { shallow, mount } from 'enzyme'
import { spy } from 'sinon'
import React from 'react'
import test from 'ava'
@@ -6,13 +6,22 @@ import toJson from 'enzyme-to-json'
import NodeLabel from './node-label'
+const mockEvent = {
+ target: { checked: true },
+ stopPropagation: () => undefined,
+ nativeEvent: { stopImmediatePropagation: () => undefined },
+}
+const baseNode = {
+ id: '0-0-0',
+ _parent: '0-0',
+ label: 'item0-0-0',
+ value: 'value0-0-0',
+ className: 'cn0-0-0',
+}
+
test('renders node label', t => {
const node = {
- id: '0-0-0',
- _parent: '0-0',
- label: 'item0-0-0',
- value: 'value0-0-0',
- className: 'cn0-0-0',
+ ...baseNode,
actions: [
{
id: 'NOT',
@@ -29,29 +38,21 @@ test('renders node label', t => {
test('notifies checkbox changes', t => {
const node = {
- id: '0-0-0',
- _parent: '0-0',
- label: 'item0-0-0',
- value: 'value0-0-0',
- className: 'cn0-0-0',
+ ...baseNode,
checked: false,
}
const onChange = spy()
const wrapper = shallow(
)
- wrapper.find('.checkbox-item').simulate('change', { target: { checked: true } })
+ wrapper.find('.checkbox-item').simulate('change', mockEvent)
t.true(onChange.calledWith('0-0-0', true))
})
test('disable checkbox if the node has disabled status', t => {
const node = {
- id: '0-0-0',
- _parent: '0-0',
+ ...baseNode,
disabled: true,
- label: 'item0-0-0',
- value: 'value0-0-0',
- className: 'cn0-0-0',
}
const wrapper = shallow(
)
@@ -61,17 +62,32 @@ test('disable checkbox if the node has disabled status', t => {
test('notifies clicks in simple mode', t => {
const node = {
- id: '0-0-0',
- _parent: '0-0',
- label: 'item0-0-0',
- value: 'value0-0-0',
- className: 'cn0-0-0',
+ ...baseNode,
checked: false,
}
const onChange = spy()
const wrapper = shallow(
)
- wrapper.find('.node-label').simulate('click')
+ wrapper.find('.node-label').simulate('click', mockEvent)
t.true(onChange.calledWith('0-0-0', true))
})
+
+test('call stopPropagation and stopImmediatePropagation when label is clicked', t => {
+ const node = {
+ ...baseNode,
+ checked: false,
+ }
+
+ const onChange = spy()
+
+ const wrapper = mount(
)
+ const event = {
+ type: 'click',
+ stopPropagation: spy(),
+ nativeEvent: { stopImmediatePropagation: spy() },
+ }
+ wrapper.find('input').prop('onChange')(event)
+ t.true(event.stopPropagation.called)
+ t.true(event.nativeEvent.stopImmediatePropagation.called)
+})
diff --git a/src/tree/index.js b/src/tree/index.js
index b254774c..52fb3283 100644
--- a/src/tree/index.js
+++ b/src/tree/index.js
@@ -24,9 +24,11 @@ class Tree extends Component {
onAction: PropTypes.func,
onCheckboxChange: PropTypes.func,
simpleSelect: PropTypes.bool,
+ radioSelect: PropTypes.bool,
showPartiallySelected: PropTypes.bool,
pageSize: PropTypes.number,
readOnly: PropTypes.bool,
+ clientId: PropTypes.string,
}
static defaultProps = {
@@ -65,12 +67,14 @@ class Tree extends Component {
keepChildrenOnSearch,
searchModeOn,
simpleSelect,
+ radioSelect,
showPartiallySelected,
readOnly,
onAction,
onChange,
onCheckboxChange,
onNodeToggle,
+ clientId,
} = props
const items = []
data.forEach(node => {
@@ -87,8 +91,10 @@ class Tree extends Component {
onNodeToggle={onNodeToggle}
onAction={onAction}
simpleSelect={simpleSelect}
+ radioSelect={radioSelect}
showPartiallySelected={showPartiallySelected}
readOnly={readOnly}
+ clientId={clientId}
/>
)
}
diff --git a/types/react-dropdown-tree-select.d.ts b/types/react-dropdown-tree-select.d.ts
index 76f9eafd..f5a21005 100644
--- a/types/react-dropdown-tree-select.d.ts
+++ b/types/react-dropdown-tree-select.d.ts
@@ -15,6 +15,10 @@ declare module "react-dropdown-tree-select" {
* NOTE this works only in combination with keepTreeOnSearch
*/
keepChildrenOnSearch?: boolean;
+ /** Keeps single selects open after selection. Defaults to `false`
+ * NOTE this works only in combination with simpleSelect or radioSelect
+ */
+ keepOpenOnSelect?: boolean;
/** The text to display as placeholder on the search box. Defaults to Choose... */
placeholderText?: string;
/** If set to true, shows the dropdown when rendered.
@@ -51,6 +55,10 @@ declare module "react-dropdown-tree-select" {
* Defaults to false
*/
simpleSelect?: boolean;
+ /** Turns the dropdown into radio select dropdown. Similar to simpleSelect but keeps tree/children. Defaults to `false`.
+ * *NOTE* if multiple nodes in data are selected, checked or isDefaultValue, only the first visited node is selected
+ */
+ radioSelect?: boolean;
/** The text to display when the search does not find results in the content list. Defaults to No matches found */
noMatchesText?: string;
/** If set to true, shows checkboxes in a partial state when one, but not all of their children are selected.