Skip to content

Commit 4916bd9

Browse files
committed
Implement tree search
1 parent d19f52b commit 4916bd9

File tree

4 files changed

+181
-8
lines changed

4 files changed

+181
-8
lines changed

src/node-renderer-default.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const NodeRendererDefault = ({
2727
draggedNode,
2828
path,
2929
treeIndex,
30+
isSearchMatch,
31+
isSearchFocus,
3032
buttons,
3133
className,
3234
style = {},
@@ -88,6 +90,8 @@ const NodeRendererDefault = ({
8890
className={styles.row +
8991
(isDragging && isOver ? ` ${styles.rowLandingPad}` : '') +
9092
(isDragging && !isOver && canDrop ? ` ${styles.rowCancelPad}` : '') +
93+
(isSearchMatch ? ` ${styles.rowSearchMatch}` : '') +
94+
(isSearchFocus ? ` ${styles.rowSearchFocus}` : '') +
9195
(className ? ` ${className}` : '')
9296
}
9397
style={{
@@ -136,9 +140,11 @@ const NodeRendererDefault = ({
136140
};
137141

138142
NodeRendererDefault.propTypes = {
139-
node: PropTypes.object.isRequired,
140-
path: PropTypes.arrayOf(PropTypes.oneOfType([ PropTypes.string, PropTypes.number ])).isRequired,
141-
treeIndex: PropTypes.number.isRequired,
143+
node: PropTypes.object.isRequired,
144+
path: PropTypes.arrayOf(PropTypes.oneOfType([ PropTypes.string, PropTypes.number ])).isRequired,
145+
treeIndex: PropTypes.number.isRequired,
146+
isSearchMatch: PropTypes.bool,
147+
isSearchFocus: PropTypes.bool,
142148

143149
scaffoldBlockPxWidth: PropTypes.number.isRequired,
144150
toggleChildrenVisibility: PropTypes.func,

src/node-renderer-default.scss

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ $row-padding: 10px;
1111
display: flex;
1212
}
1313

14+
/**
15+
* The outline of where the element will go if dropped, displayed while dragging
16+
*/
1417
.rowLandingPad {
1518
border: none !important;
1619
box-shadow: none !important;
@@ -33,6 +36,9 @@ $row-padding: 10px;
3336
}
3437
}
3538

39+
/**
40+
* Alternate appearance of the landing pad when the dragged location is invalid
41+
*/
3642
.rowCancelPad {
3743
@extend .rowLandingPad;
3844

@@ -41,6 +47,20 @@ $row-padding: 10px;
4147
}
4248
}
4349

50+
/**
51+
* Nodes matching the search conditions are highlighted
52+
*/
53+
.rowSearchMatch {
54+
outline: solid 3px #0080FF;
55+
}
56+
57+
/**
58+
* The node that matches the search conditions and is currently focused
59+
*/
60+
.rowSearchFocus {
61+
outline: solid 3px #FC6421;
62+
}
63+
4464
%rowItem {
4565
display: inline-block;
4666
vertical-align: middle;

src/react-sortable-tree.js

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
removeNodeAtPath,
1616
insertNode,
1717
getDescendantCount,
18+
find,
1819
} from './utils/tree-data-utils';
1920
import {
2021
swapRows,
@@ -23,6 +24,7 @@ import {
2324
defaultGetNodeKey,
2425
defaultToggleChildrenVisibility,
2526
defaultMoveNode,
27+
defaultSearchMethod,
2628
} from './utils/default-handlers';
2729
import {
2830
dndWrapRoot,
@@ -63,6 +65,8 @@ class ReactSortableTree extends Component {
6365
swapLength: null,
6466
swapDepth: null,
6567
rows: this.getRows(props.treeData),
68+
searchMatches: [],
69+
searchFocusTreeIndex: null,
6670
};
6771

6872
this.startDrag = this.startDrag.bind(this);
@@ -72,12 +76,21 @@ class ReactSortableTree extends Component {
7276

7377
componentWillMount() {
7478
this.loadLazyChildren();
79+
this.search(this.props, false, false);
80+
this.ignoreOneTreeUpdate = false;
7581
}
7682

7783
componentWillReceiveProps(nextProps) {
84+
this.setState({ searchFocusTreeIndex: null });
7885
if (this.props.treeData !== nextProps.treeData) {
79-
// Load any children defined by a function
80-
this.loadLazyChildren(nextProps);
86+
// Ignore updates caused by search, in order to avoid infinite looping
87+
if (this.ignoreOneTreeUpdate) {
88+
this.ignoreOneTreeUpdate = false;
89+
} else {
90+
this.loadLazyChildren(nextProps);
91+
// Load any children defined by a function
92+
this.search(nextProps, false, false);
93+
}
8194

8295
// Calculate the rows to be shown from the new tree data
8396
this.setState({
@@ -87,6 +100,12 @@ class ReactSortableTree extends Component {
87100
swapDepth: null,
88101
rows: this.getRows(nextProps.treeData),
89102
});
103+
} else if (this.props.searchQuery !== nextProps.searchQuery ||
104+
this.props.searchMethod !== nextProps.searchMethod
105+
) {
106+
this.search(nextProps);
107+
} else if (this.props.searchFocusOffset !== nextProps.searchFocusOffset) {
108+
this.search(nextProps, true, true, true);
90109
}
91110
}
92111

@@ -98,6 +117,68 @@ class ReactSortableTree extends Component {
98117
});
99118
}
100119

120+
search(props = this.props, seekIndex = true, expand = true, singleSearch = false) {
121+
const {
122+
treeData,
123+
updateTreeData,
124+
searchFinishCallback,
125+
searchQuery,
126+
searchMethod,
127+
searchFocusOffset,
128+
} = props;
129+
130+
// Skip search if no conditions are specified
131+
if ((searchQuery === null || typeof searchQuery === 'undefined' || String(searchQuery) === '') &&
132+
!searchMethod
133+
) {
134+
this.setState({
135+
searchMatches: [],
136+
});
137+
138+
if (searchFinishCallback) {
139+
searchFinishCallback([]);
140+
}
141+
142+
return;
143+
}
144+
145+
const {
146+
treeData: expandedTreeData,
147+
matches: searchMatches,
148+
} = find({
149+
getNodeKey: this.getNodeKey,
150+
treeData,
151+
searchQuery,
152+
searchMethod: searchMethod || defaultSearchMethod,
153+
searchFocusOffset,
154+
expandAllMatchPaths: expand && !singleSearch,
155+
expandFocusMatchPaths: expand && true,
156+
});
157+
158+
// Update the tree with data leaving all paths leading to matching nodes open
159+
if (expand) {
160+
this.ignoreOneTreeUpdate = true; // Prevents infinite loop
161+
updateTreeData(expandedTreeData);
162+
}
163+
164+
if (searchFinishCallback) {
165+
searchFinishCallback(searchMatches);
166+
}
167+
168+
let searchFocusTreeIndex = null;
169+
if (seekIndex &&
170+
searchFocusOffset !== null &&
171+
searchFocusOffset < searchMatches.length
172+
) {
173+
searchFocusTreeIndex = searchMatches[searchFocusOffset].treeIndex;
174+
}
175+
176+
this.setState({
177+
searchMatches,
178+
searchFocusTreeIndex,
179+
});
180+
}
181+
101182
startDrag({ path }) {
102183
const draggingTreeData = removeNodeAtPath({
103184
treeData: this.props.treeData,
@@ -198,7 +279,18 @@ class ReactSortableTree extends Component {
198279
innerStyle,
199280
rowHeight,
200281
} = this.props;
201-
const { rows } = this.state;
282+
const {
283+
rows,
284+
searchMatches,
285+
searchFocusTreeIndex,
286+
} = this.state;
287+
288+
// Get indices for rows that match the search conditions
289+
const matchIndices = {};
290+
searchMatches.forEach(({ treeIndex: tIndex }, i) => { matchIndices[tIndex] = i; });
291+
292+
// Seek to the focused search result if there is one specified
293+
const scrollToInfo = searchFocusTreeIndex !== null ? { scrollToIndex: searchFocusTreeIndex } : {};
202294

203295
return (
204296
<div
@@ -208,6 +300,8 @@ class ReactSortableTree extends Component {
208300
<AutoSizer>
209301
{({height, width}) => (
210302
<List
303+
{...scrollToInfo}
304+
scrollToAlignment="start"
211305
className={styles.virtualScrollOverride}
212306
width={width}
213307
height={height}
@@ -220,7 +314,8 @@ class ReactSortableTree extends Component {
220314
index,
221315
key,
222316
rowStyle,
223-
() => (rows[index - 1] || null)
317+
() => (rows[index - 1] || null),
318+
matchIndices
224319
)}
225320
/>
226321
)}
@@ -229,13 +324,19 @@ class ReactSortableTree extends Component {
229324
);
230325
}
231326

232-
renderRow({ node, path, lowerSiblingCounts, treeIndex }, listIndex, key, style, getPrevRow) {
327+
renderRow({ node, path, lowerSiblingCounts, treeIndex }, listIndex, key, style, getPrevRow, matchIndices) {
233328
const NodeContentRenderer = this.nodeContentRenderer;
329+
const isSearchMatch = treeIndex in matchIndices;
330+
const isSearchFocus = isSearchMatch &&
331+
matchIndices[treeIndex] === this.props.searchFocusOffset;
332+
234333
const nodeProps = !this.props.generateNodeProps ? {} : this.props.generateNodeProps({
235334
node,
236335
path,
237336
lowerSiblingCounts,
238337
treeIndex,
338+
isSearchMatch,
339+
isSearchFocus,
239340
});
240341

241342
return (
@@ -258,6 +359,8 @@ class ReactSortableTree extends Component {
258359
<NodeContentRenderer
259360
node={node}
260361
path={path}
362+
isSearchMatch={isSearchMatch}
363+
isSearchFocus={isSearchFocus}
261364
treeIndex={treeIndex}
262365
startDrag={this.startDrag}
263366
endDrag={this.endDrag}
@@ -291,6 +394,12 @@ ReactSortableTree.propTypes = {
291394

292395
maxDepth: PropTypes.number,
293396

397+
// Search stuff
398+
searchQuery: PropTypes.oneOfType([ PropTypes.number, PropTypes.string ]),
399+
searchFocusOffset: PropTypes.number,
400+
searchMethod: PropTypes.func,
401+
searchFinishCallback: PropTypes.func, // eslint-disable-line react/no-unused-prop-types
402+
294403
nodeContentRenderer: PropTypes.any,
295404
generateNodeProps: PropTypes.func,
296405

@@ -305,6 +414,7 @@ ReactSortableTree.defaultProps = {
305414
innerStyle: {},
306415
scaffoldBlockPxWidth: 44,
307416
loadCollapsedLazyChildren: false,
417+
searchQuery: null,
308418
};
309419

310420
export default dndWrapRoot(ReactSortableTree);

src/utils/default-handlers.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,40 @@ export function defaultMoveNode({ node: newNode, depth, minimumTreeIndex }) {
2525
expandParent: true,
2626
}).treeData);
2727
}
28+
29+
// Cheap hack to get the text of a react object
30+
function getReactElementText(parent) {
31+
if (typeof parent === 'string') {
32+
return parent;
33+
}
34+
35+
if (typeof parent !== 'object' ||
36+
!parent.props ||
37+
!parent.props.children ||
38+
(typeof parent.props.children !== 'string' && typeof parent.props.children !== 'object')
39+
) {
40+
return '';
41+
}
42+
43+
if (typeof parent.props.children === 'string') {
44+
return parent.props.children;
45+
}
46+
47+
return parent.props.children.map(child => getReactElementText(child)).join('');
48+
}
49+
50+
// Search for a query string inside a node property
51+
function stringSearch(key, searchQuery, node, path, treeIndex) {
52+
if (typeof node[key] === 'function') {
53+
return String(node[key]({ node, path, treeIndex })).indexOf(searchQuery) > -1;
54+
} else if (typeof node[key] === 'object') {
55+
return getReactElementText(node[key]).indexOf(searchQuery) > -1;
56+
}
57+
58+
return node[key] && String(node[key]).indexOf(searchQuery) > -1;
59+
}
60+
61+
export function defaultSearchMethod({ node, path, treeIndex, searchQuery }) {
62+
return stringSearch('title', searchQuery, node, path, treeIndex) ||
63+
stringSearch('subtitle', searchQuery, node, path, treeIndex);
64+
}

0 commit comments

Comments
 (0)