Skip to content

Commit d0d219b

Browse files
Gregcop1mrchief
authored andcommitted
feat: add selection level (#55) ✨
* feat: add a prop to disable selection on specific levels * feat: move logic to 'disabled' property of a node * fix: @mrchief review * feat: node now inherits disabled status of parents if one has been setted on ancestor level * fix: code climate * refactor: Move initial state checks to its own file Helps to group and run tests in parallel (cherry picked from commit f6e1eed) * test: Add failing tests (cherry picked from commit 1fd22b5) * fix: Remove cognitive complexity - Also avoids walking up the tree for each node - Fixes failing tests where state props are defined but are set to false (cherry picked from commit d0fd2e9)
1 parent d1015d6 commit d0d219b

File tree

8 files changed

+199
-42
lines changed

8 files changed

+199
-42
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ Data for rendering the tree select items. The object requires the following stru
188188
value, // required: Checkbox value
189189
children, // optional: Array of child objects
190190
checked, // optional: Initial state of checkbox. if true, checkbox is selected and corresponding pill is rendered.
191+
disabled, // optional: Selectable state of checkbox. if true, the checkbox is disabled and the node is not selectable.
191192
expanded, // optional: If true, the node is expanded (children of children nodes are not expanded by default unless children nodes also have expanded: true).
192193
className, // optional: Additional css class for the node. This is helpful to style the nodes your way
193194
tagClassName, // optional: Css class for the corresponding tag. Use this to add custom style the pill corresponding to the node.

docs/demo-data.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[{"label":"VP Accounting","tagClassName":"special","children":[{"label":"iWay","children":[{"label":"Universidad de Especialidades del Espíritu Santo"},{"label":"Marmara University"},{"label":"Baghdad College of Pharmacy"}]},{"label":"KDB","children":[{"label":"Latvian University of Agriculture"},{"label":"Dublin Institute of Technology"}]},{"label":"Justice","children":[{"label":"Baylor University"},{"label":"Massachusetts College of Art"},{"label":"Universidad Técnica Latinoamericana"},{"label":"Saint Louis College"},{"label":"Scott Christian University"}]},{"label":"Utilization Review","children":[{"label":"University of Minnesota - Twin Cities Campus"},{"label":"Moldova State Agricultural University"},{"label":"Andrews University"},{"label":"Usmanu Danfodiyo University Sokoto"}]},{"label":"Norton Utilities","children":[{"label":"Universidad Autónoma del Caribe"},{"label":"National University of Uzbekistan"},{"label":"Ladoke Akintola University of Technology"},{"label":"Kohat University of Science and Technology (KUST)"},{"label":"Hvanneyri Agricultural University"}]}]},
2-
{"label":"Database Administrator III","children":[{"label":"TFS","children":[{"label":"University of Jazeera"},{"label":"Technical University of Crete"},{"label":"Ecole Nationale Supérieure d'Agronomie et des Industries Alimentaires"},{"label":"Ho Chi Minh City University of Natural Sciences"}]},{"label":"Overhaul","children":[{"label":"Technological University (Taunggyi)"},{"label":"Universidad de Las Palmas de Gran Canaria"},{"label":"Olympia College"},{"label":"Franklin and Marshall College"},{"label":"State University of New York College of Environmental Science and Forestry"}]},{"label":"GTK","children":[{"label":"Salisbury State University"},{"label":"Evangelische Fachhochschule für Religionspädagogik, und Gemeindediakonie Moritzburg"},{"label":"Kilimanjaro Christian Medical College"}]},{"label":"SRP","children":[{"label":"Toyo Gakuen University"},{"label":"Riyadh College of Dentistry and Pharmacy"},{"label":"Aichi Gakusen University"}]}]},
2+
{"label":"Database Administrator III","disabled": true,"children":[{"label":"TFS","children":[{"label":"University of Jazeera"},{"label":"Technical University of Crete"},{"label":"Ecole Nationale Supérieure d'Agronomie et des Industries Alimentaires"},{"label":"Ho Chi Minh City University of Natural Sciences"}]},{"label":"Overhaul","children":[{"label":"Technological University (Taunggyi)"},{"label":"Universidad de Las Palmas de Gran Canaria"},{"label":"Olympia College"},{"label":"Franklin and Marshall College"},{"label":"State University of New York College of Environmental Science and Forestry"}]},{"label":"GTK","children":[{"label":"Salisbury State University"},{"label":"Evangelische Fachhochschule für Religionspädagogik, und Gemeindediakonie Moritzburg"},{"label":"Kilimanjaro Christian Medical College"}]},{"label":"SRP","children":[{"label":"Toyo Gakuen University"},{"label":"Riyadh College of Dentistry and Pharmacy"},{"label":"Aichi Gakusen University"}]}]},
33
{"label":"Assistant Manager","children":[{"label":"Risk Analysis","children":[{"label":"Seijo University"},{"label":"University of Economics Varna"},{"label":"College of Technology at Riyadh"}]},{"label":"UV Mapping","children":[{"label":"Universidad de La Sabana"},{"label":"Pamukkale University"}]}]},
44
{"label":"Quality Engineer","children":[{"label":"Enzyme Kinetics","children":[{"label":"Universidad del Valle de Guatemala"},{"label":"Ecole Nationale Supérieure d'Electronique, d'Electrotechnique, d'Informatique et d'Hydraulique de Toulouse"},{"label":"Kota Bharu Polytechnic"},{"label":"College of Technology at Kharj"}]},{"label":"Gastroenterology","children":[{"label":"Balochistan University of Engineering and Technology Khuzdar"},{"label":"Université de Cergy-Pontoise"},{"label":"Frederick University"}]},{"label":"ADP Payroll","children":[{"label":"National University"},{"label":"Ecole de l'Air"},{"label":"Vietnam National University of Agriculture"},{"label":"St. Petersburg State University of Aerospace Instrumentation"}]}]},
55
{"label":"Senior Sales Associate","children":[{"label":"RSVP","children":[{"label":"Islamic Azad University, Ahar"},{"label":"Okinawa International University"},{"label":"Karlshochschule International University"}]},{"label":"IxChariot","children":[{"label":"Cambodia University of Specialties"},{"label":"Ecole Supérieure des Techniques Industrielles et des Textiles"}]}]},
@@ -17,4 +17,4 @@
1717
{"label":"Account Coordinator","children":[{"label":"Biostatistics","children":[{"label":"Al-Bukhari International University"},{"label":"Technical University of Denmark"},{"label":"Postgraduate lnstitute of Medical Education and Research"}]},{"label":"FM","children":[{"label":"University of Oxford"},{"label":"Lawrence University"},{"label":"Okayama University"}]},{"label":"Microsoft Certified Professional","children":[{"label":"Universidade Católica de Brasília"},{"label":"Georgia Institute of Technology"},{"label":"University of Petrosani"}]}]},
1818
{"label":"Payment Adjustment Coordinator","children":[{"label":"Federal Grants Management","children":[{"label":"Christ University"},{"label":"Janos Selye University"},{"label":"Zagazig University"},{"label":"Constantin Brancoveanu University Pitesti"},{"label":"Southwest University of Political Science and Law"}]},{"label":"Company Set-up","children":[{"label":"Ball State University"},{"label":"Mustafa Kemal University"},{"label":"Transylvania University"}]},{"label":"CDMA","children":[{"label":"College of Telecommunication & Information "},{"label":"Nagasaki Prefectural University"},{"label":"Gustav-Siewerth-Akademie"}]},{"label":"Overhead Cranes","children":[{"label":"Universidad de Pamplona"},{"label":"Bindura University of Science Education"},{"label":"Daiichi University of Economics"},{"label":"Wirtschaftsuniversität Wien"}]},{"label":"CDO","children":[{"label":"Design Institute of San Diego"},{"label":"Wellspring University"},{"label":"Franciscan School of Theology"}]}]},
1919
{"label":"Assistant Manager","children":[{"label":"SQL Server Management Studio","children":[{"label":"University of Sudbury"},{"label":"Evangelische Fachhochschule Berlin, Fachhochschule für Sozialarbeit und Sozialpädagogik"},{"label":"Vitebsk State University"},{"label":"San Jose Christian College"},{"label":"Ivanovo State University"}]},{"label":"Abstracting","children":[{"label":"Adeyemi College of Education"},{"label":"Université de Sherbrooke"},{"label":"University College of Applied Sciences"},{"label":"Johns Hopkins University, SAIS Bologna Center"}]},{"label":"WTL","children":[{"label":"Universidad de Córdoba"},{"label":"Institut National Polytechnique de Grenoble"},{"label":"Kyonggi University"}]}]},
20-
{"label":"Professor","children":[{"label":"People Skills","children":[{"label":"University of Calcutta"},{"label":"Universidad del Valle del Cauca"},{"label":"FAST - National University of Computer and Emerging Sciences (NUCES)"}]},{"label":"Workforce Development","children":[{"label":"Shandong Medical University"},{"label":"Al Khawarizmi International College"},{"label":"Nippon Dental University"},{"label":"Komsomolsk-on-Amur State Technical University"},{"label":"Lingnan University"}]},{"label":"Digital Journalism","children":[{"label":"The College of St. Scholastica"},{"label":"Universidad Autónoma de la Ciudad de México"},{"label":"University of Information Technology and Management in Rzeszow"},{"label":"Liaquat University of Medical & Health Sciences Jamshoro"}]},{"label":"Short Films","children":[{"label":"Universidad Católica de Valencia"},{"label":"Columbia International University"},{"label":"Framingham State College"},{"label":"Gurukul University"},{"label":"NTI University"}]},{"label":"XML Programming","children":[{"label":"Victoria University"},{"label":"Andrews University"},{"label":"Centre Universitaire d'Oum El Bouaghi"},{"label":"Dilla University"}]}]}]
20+
{"label":"Professor","children":[{"label":"People Skills","children":[{"label":"University of Calcutta"},{"label":"Universidad del Valle del Cauca"},{"label":"FAST - National University of Computer and Emerging Sciences (NUCES)"}]},{"label":"Workforce Development","children":[{"label":"Shandong Medical University"},{"label":"Al Khawarizmi International College"},{"label":"Nippon Dental University"},{"label":"Komsomolsk-on-Amur State Technical University"},{"label":"Lingnan University"}]},{"label":"Digital Journalism","children":[{"label":"The College of St. Scholastica"},{"label":"Universidad Autónoma de la Ciudad de México"},{"label":"University of Information Technology and Management in Rzeszow"},{"label":"Liaquat University of Medical & Health Sciences Jamshoro"}]},{"label":"Short Films","children":[{"label":"Universidad Católica de Valencia"},{"label":"Columbia International University"},{"label":"Framingham State College"},{"label":"Gurukul University"},{"label":"NTI University"}]},{"label":"XML Programming","children":[{"label":"Victoria University"},{"label":"Andrews University"},{"label":"Centre Universitaire d'Oum El Bouaghi"},{"label":"Dilla University"}]}]}]

src/tree-manager/flatten-tree.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,24 @@ function flattenTree (tree) {
9696
return list
9797
}
9898

99+
/**
100+
* If the node didn't specify anything on its own
101+
* figure out the initial state based on parent
102+
* @param {object} node [curernt node]
103+
* @param {object} parent [node's immediate parent]
104+
*/
105+
function setInitialStateProps (node, parent = {}) {
106+
const stateProps = ['checked', 'disabled']
107+
for (let index = 0; index < stateProps.length; index++) {
108+
const prop = stateProps[index]
109+
110+
// if and only if, node doesn't explicitly define a prop, grab it from parent
111+
if (node[prop] === undefined && parent[prop] !== undefined) {
112+
node[prop] = parent[prop]
113+
}
114+
}
115+
}
116+
99117
function walkNodes ({nodes, list = new Map(), parent, depth = 0}) {
100118
nodes.forEach((node, i) => {
101119
node._depth = depth
@@ -108,6 +126,8 @@ function walkNodes ({nodes, list = new Map(), parent, depth = 0}) {
108126
node._id = node.id || `${i}`
109127
}
110128

129+
setInitialStateProps(node, parent)
130+
111131
list.set(node._id, node)
112132
if (node.children) {
113133
node._children = []

src/tree-manager/index.js

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ class TreeManager {
55
constructor (tree) {
66
this._src = tree
77
this.tree = flattenTree(JSON.parse(JSON.stringify(tree)))
8-
this.tree.forEach(node => { this.setInitialCheckState(node) })
98
this.searchMaps = new Map()
109
}
1110

@@ -83,33 +82,6 @@ class TreeManager {
8382
return this.tree
8483
}
8584

86-
/**
87-
* If the node didn't specify anything on its own
88-
* figure out the initial state based on parent selections
89-
* @param {object} node [description]
90-
*/
91-
setInitialCheckState (node) {
92-
if (node.checked === undefined) node.checked = this.getNodeCheckedState(node)
93-
}
94-
95-
/**
96-
* Figure out the check state based on parent selections.
97-
* @param {[type]} node [description]
98-
* @param {[type]} tree [description]
99-
* @return {[type]} [description]
100-
*/
101-
getNodeCheckedState (node) {
102-
let parentCheckState = false
103-
let parent = node._parent
104-
while (parent && !parentCheckState) {
105-
const parentNode = this.getNodeById(parent)
106-
parentCheckState = parentNode.checked || false
107-
parent = parentNode._parent
108-
}
109-
110-
return parentCheckState
111-
}
112-
11385
setNodeCheckedState (id, checked) {
11486
const node = this.getNodeById(id)
11587
node.checked = checked

src/tree-manager/initialState.test.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import test from 'ava'
2+
import TreeManager from './index'
3+
4+
// eslint-disable-next-line max-len
5+
test('should set initial disabled state based on parent disabled state when node disabled state is not defined', t => {
6+
const tree = {
7+
id: 'i1',
8+
label: 'l1',
9+
value: 'v1',
10+
children: [{
11+
id: 'c1',
12+
label: 'l1c1',
13+
value: 'l1v1'
14+
}],
15+
disabled: true
16+
}
17+
const manager = new TreeManager(tree)
18+
t.true(manager.getNodeById('c1').disabled)
19+
})
20+
21+
// should set initial disabled state based on parent disabled state
22+
// when node disabled state is not defined and parent checked is defined
23+
test('when node disabled state is not defined and parent checked is defined', t => {
24+
const tree = {
25+
id: 'i1',
26+
label: 'l1',
27+
value: 'v1',
28+
children: [{
29+
id: 'c1',
30+
label: 'l1c1',
31+
value: 'l1v1'
32+
}],
33+
disabled: true
34+
}
35+
const manager = new TreeManager(tree)
36+
t.true(manager.getNodeById('c1').disabled)
37+
})
38+
39+
// should set initial disabled state based on parent disabled state
40+
// when node disabled state is not defined and parent checked is defined
41+
test('when node disabled state is not defined and parent checked is defined', t => {
42+
const tree = {
43+
id: 'i1',
44+
label: 'l1',
45+
value: 'v1',
46+
children: [{
47+
id: 'c1',
48+
label: 'l1c1',
49+
value: 'l1v1',
50+
checked: true,
51+
children: [{
52+
id: 'gc1',
53+
label: 'l2c1',
54+
value: 'l2v1'
55+
}]
56+
}],
57+
disabled: true
58+
}
59+
const manager = new TreeManager(tree)
60+
t.true(manager.getNodeById('c1').disabled)
61+
t.true(manager.getNodeById('gc1').disabled)
62+
})
63+
64+
// should set initial disabled state based on parent disabled state
65+
// when node disabled state is not defined and parent checked is defined
66+
test('when node disabled state is not defined and grand parent checked is defined', t => {
67+
const tree = {
68+
id: 'i1',
69+
label: 'l1',
70+
value: 'v1',
71+
children: [{
72+
id: 'c1',
73+
label: 'l1c1',
74+
value: 'l1v1',
75+
disabled: true,
76+
children: [{
77+
id: 'gc1',
78+
label: 'l2c1',
79+
value: 'l2v1'
80+
}]
81+
}],
82+
checked: true
83+
}
84+
const manager = new TreeManager(tree)
85+
t.true(manager.getNodeById('c1').disabled)
86+
t.true(manager.getNodeById('gc1').disabled)
87+
t.true(manager.getNodeById('c1').checked)
88+
t.true(manager.getNodeById('gc1').checked)
89+
})
90+
91+
// eslint-disable-next-line max-len
92+
test('when node disabled is not defined, parent checked/disabled is defined and grand parent checked/disabled is defined', t => {
93+
const tree = {
94+
id: 'i1',
95+
label: 'l1',
96+
value: 'v1',
97+
children: [{
98+
id: 'c1',
99+
label: 'l1c1',
100+
value: 'l1v1',
101+
disabled: false,
102+
checked: false,
103+
children: [{
104+
id: 'gc1',
105+
label: 'l2c1',
106+
value: 'l2v1'
107+
}]
108+
}],
109+
checked: true,
110+
disabled: true
111+
}
112+
const manager = new TreeManager(tree)
113+
t.false(manager.getNodeById('c1').disabled)
114+
t.false(manager.getNodeById('c1').checked)
115+
t.falsy(manager.getNodeById('gc1').checked)
116+
t.falsy(manager.getNodeById('gc1').disabled)
117+
})

src/tree-node/index.js

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,43 @@ import styles from './index.css'
77

88
const cx = cn.bind(styles)
99

10-
const TreeNode = props => {
11-
const { keepTreeOnSearch, node, searchModeOn, onNodeToggle, onCheckboxChange, onAction } = props
12-
const isLeaf = isEmpty(node._children)
13-
const hasMatchInChildren = keepTreeOnSearch && node.matchInChildren
14-
const nodeCx = { leaf: isLeaf, tree: !isLeaf, hide: node.hide, 'match-in-children': hasMatchInChildren }
15-
const liCx = cx('node', nodeCx, node.className)
16-
const toggleCx = cx('toggle', { expanded: !isLeaf && node.expanded, collapsed: !isLeaf && !node.expanded })
10+
const isLeaf = (node) => isEmpty(node._children)
11+
12+
const getNodeCx = (props) => {
13+
const { keepTreeOnSearch, node } = props
14+
15+
return cx(
16+
'node',
17+
{
18+
leaf: isLeaf(node),
19+
tree: !isLeaf(node),
20+
disabled: node.disabled,
21+
hide: node.hide,
22+
'match-in-children': keepTreeOnSearch && node.matchInChildren
23+
},
24+
node.className
25+
)
26+
}
27+
28+
const getToggleCx = ({ node }) => {
29+
return cx(
30+
'toggle',
31+
{ expanded: !isLeaf(node) && node.expanded, collapsed: !isLeaf(node) && !node.expanded }
32+
)
33+
}
34+
35+
const getNodeActions = (props) => {
36+
const {node, onAction} = props
1737

38+
return (node.actions || []).map((a, idx) => (
39+
<Action key={`action-${idx}`} {...a} actionData={{ action: a.id, node }} onAction={onAction} />
40+
))
41+
}
42+
43+
const TreeNode = props => {
44+
const { keepTreeOnSearch, node, searchModeOn, onNodeToggle, onCheckboxChange } = props
45+
const liCx = getNodeCx(props)
46+
const toggleCx = getToggleCx(props)
1847
return (
1948
<li className={liCx} style={keepTreeOnSearch || !searchModeOn ? { paddingLeft: `${node._depth * 20}px` } : {}}>
2049
<i className={toggleCx} onClick={() => onNodeToggle(node._id)} />
@@ -26,12 +55,11 @@ const TreeNode = props => {
2655
checked={node.checked}
2756
onChange={e => onCheckboxChange(node._id, e.target.checked)}
2857
value={node.value}
58+
disabled={node.disabled}
2959
/>
3060
<span className="node-label">{node.label}</span>
3161
</label>
32-
{(node.actions || []).map((a, idx) => (
33-
<Action key={`action-${idx}`} {...a} actionData={{ action: a.id, node }} onAction={onAction} />
34-
))}
62+
{getNodeActions(props)}
3563
</li>
3664
)
3765
}
@@ -46,7 +74,8 @@ TreeNode.propTypes = {
4674
title: PropTypes.string,
4775
label: PropTypes.string.isRequired,
4876
checked: PropTypes.bool,
49-
expanded: PropTypes.bool
77+
expanded: PropTypes.bool,
78+
disabled: PropTypes.bool
5079
}).isRequired,
5180
keepTreeOnSearch: PropTypes.bool,
5281
searchModeOn: PropTypes.bool,

src/tree-node/index.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ test('renders tree node', t => {
3131
t.true(wrapper.find('label').exists())
3232
t.true(wrapper.find('.checkbox-item').exists())
3333
t.true(hasGap(wrapper))
34+
t.false(wrapper.hasClass('disabled'))
3435
})
3536

3637
test('notifies checkbox changes', t => {
@@ -79,3 +80,19 @@ test('remove gap during search', t => {
7980

8081
t.false(hasGap(wrapper))
8182
})
83+
84+
test('disable checkbox if the node has disabled status', t => {
85+
const node = {
86+
_id: '0-0-0',
87+
_parent: '0-0',
88+
disabled: true,
89+
label: 'item1-1-1',
90+
value: 'value1-1-1',
91+
className: 'cn0-0-0'
92+
}
93+
94+
const wrapper = shallow(<TreeNode node={node} searchModeOn={true} />)
95+
96+
t.true(wrapper.hasClass('disabled'))
97+
t.true(wrapper.find('.checkbox-item').is('[disabled]'))
98+
})

src/tree/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ const shouldRenderNode = (node, searchModeOn, data) => {
1212
}
1313

1414
const getNodes = props => {
15-
const { data, keepTreeOnSearch, searchModeOn, onAction, onChange, onCheckboxChange, onNodeToggle } = props
15+
const { data, keepTreeOnSearch, searchModeOn } = props
16+
const { onAction, onChange, onCheckboxChange, onNodeToggle } = props
1617
const items = []
1718
data.forEach((node, key) => {
1819
if (shouldRenderNode(node, searchModeOn, data)) {

0 commit comments

Comments
 (0)