@onehat/ui 0.2.57 → 0.2.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1143 @@
1
+ import React, { useState, useEffect, useRef, useMemo, } from 'react';
2
+ import {
3
+ Column,
4
+ FlatList,
5
+ Modal,
6
+ Pressable,
7
+ Icon,
8
+ Row,
9
+ Text,
10
+ } from 'native-base';
11
+ import {
12
+ SELECTION_MODE_SINGLE,
13
+ SELECTION_MODE_MULTI,
14
+ } from '../../Constants/Selection.js';
15
+ import {
16
+ VERTICAL,
17
+ } from '../../Constants/Directions.js';
18
+ import {
19
+ DROP_POSITION_BEFORE,
20
+ DROP_POSITION_AFTER,
21
+ } from '../../Constants/Tree.js';
22
+ import * as colourMixer from '@k-renwick/colour-mixer'
23
+ import UiGlobals from '../../UiGlobals.js';
24
+ import useForceUpdate from '../../Hooks/useForceUpdate.js';
25
+ import withContextMenu from '../Hoc/withContextMenu.js';
26
+ import withAlert from '../Hoc/withAlert.js';
27
+ import withData from '../Hoc/withData.js';
28
+ import withEvents from '../Hoc/withEvents.js';
29
+ import withSideEditor from '../Hoc/withSideEditor.js';
30
+ import withFilters from '../Hoc/withFilters.js';
31
+ import withPresetButtons from '../Hoc/withPresetButtons.js';
32
+ import withMultiSelection from '../Hoc/withMultiSelection.js';
33
+ import withSelection from '../Hoc/withSelection.js';
34
+ import withWindowedEditor from '../Hoc/withWindowedEditor.js';
35
+ import testProps from '../../Functions/testProps.js';
36
+ import nbToRgb from '../../Functions/nbToRgb.js';
37
+ import TreeNode, { ReorderableTreeNode } from './TreeNode.js';
38
+ import FormPanel from '../Panel/FormPanel.js';
39
+ import Input from '../Form/Field/Input.js';
40
+ import IconButton from '../Buttons/IconButton.js';
41
+ import Circle from '../Icons/Circle.js';
42
+ import Collapse from '../Icons/Collapse.js';
43
+ import FolderClosed from '../Icons/FolderClosed.js';
44
+ import FolderOpen from '../Icons/FolderOpen.js';
45
+ import MagnifyingGlass from '../Icons/MagnifyingGlass.js';
46
+ import NoReorderRows from '../Icons/NoReorderRows.js';
47
+ import ReorderRows from '../Icons/ReorderRows.js';
48
+ import PaginationToolbar from '../Toolbar/PaginationToolbar.js';
49
+ import NoRecordsFound from './NoRecordsFound.js';
50
+ import Toolbar from '../Toolbar/Toolbar.js';
51
+ import _ from 'lodash';
52
+
53
+
54
+ // Tree requires the use of HOC withSelection() whenever it's used.
55
+ // The default export is *with* the HOC. A separate *raw* component is
56
+ // exported which can be combined with many HOCs for various functionality.
57
+
58
+
59
+ //////////////////////
60
+ //////////////////////
61
+
62
+ // I'm thinking if a repository senses that it's a tree, then at initial load
63
+ // it should get the root node +1 level of children.
64
+ //
65
+ // How would it then subsequently get the proper children?
66
+ // i.e. When a node gets its children, how will it do this
67
+ // while maintaining the nodes that already exist there?
68
+ // We don't want it to *replace* all exisitng nodes!
69
+ //
70
+ // And if the repository does a reload, should it just get root+1 again?
71
+ // Changing filters would potentially change the tree structure.
72
+ // Changing sorting would only change the ordering, not what is expanded/collapsed or visible/invisible.
73
+
74
+
75
+
76
+ // Need to take into account whether using Repository or data.
77
+ // If using data, everything exists at once. What format will data be in?
78
+ // How does this interface with Repository?
79
+ // Maybe if Repository is not AjaxRepository, everything needs to be present at once!
80
+
81
+
82
+ // isRootVisible
83
+
84
+ //////////////////////
85
+ //////////////////////
86
+
87
+
88
+
89
+
90
+
91
+
92
+ export function Tree(props) {
93
+ const {
94
+ isRootVisible = true,
95
+ getAdditionalParams = () => { // URL params needed to get nodes from server (e.g, { venue_id: 1, getEquipment: true, getRentalEquipment: false, }), in addition to filters.
96
+ return {};
97
+ },
98
+ getNodeText = (item) => { // extracts model/data and decides what the row text should be
99
+ return item.displayValue;
100
+ },
101
+ getNodeIcon = (item, isExpanded) => { // decides what icon to show for this node
102
+ let icon;
103
+ if (item.hasChildren) {
104
+ if (isExpanded) {
105
+ icon = FolderOpen;
106
+ } else {
107
+ icon = FolderClosed;
108
+ }
109
+ } else {
110
+ icon = Circle;
111
+ }
112
+ return icon;
113
+ },
114
+ nodeProps = (item) => {
115
+ return {};
116
+ },
117
+ noneFoundText,
118
+ disableLoadingIndicator = false,
119
+ disableSelectorSelected = false,
120
+ showHovers = true,
121
+ canNodesReorder = false,
122
+ allowToggleSelection = true, // i.e. single click with no shift key toggles the selection of the node clicked on
123
+ disableBottomToolbar = false,
124
+ bottomToolbar = null,
125
+ topToolbar = null,
126
+ additionalToolbarButtons = [],
127
+
128
+ // withEditor
129
+ onAdd,
130
+ onEdit,
131
+ onDelete,
132
+ onView,
133
+ onDuplicate,
134
+ onReset,
135
+ onContextMenu,
136
+
137
+ // withData
138
+ Repository,
139
+ data,
140
+ fields,
141
+ idField,
142
+ displayField,
143
+ idIx,
144
+ displayIx,
145
+
146
+ // withSelection
147
+ selection,
148
+ setSelection,
149
+ selectionMode,
150
+ removeFromSelection,
151
+ addToSelection,
152
+ deselectAll,
153
+ selectRangeTo,
154
+ isInSelection,
155
+ noSelectorMeansNoResults = false,
156
+
157
+ // DataMgt
158
+ selectorId,
159
+ selectorSelected,
160
+
161
+ } = props,
162
+ styles = UiGlobals.styles,
163
+ forceUpdate = useForceUpdate(),
164
+ treeRef = useRef(),
165
+ [isReady, setIsReady] = useState(false),
166
+ [isLoading, setIsLoading] = useState(false),
167
+ [isReorderMode, setIsReorderMode] = useState(false),
168
+ [isSearchModalShown, setIsSearchModalShown] = useState(false),
169
+ [treeNodeData, setTreeNodeData] = useState({}),
170
+ [searchFormData, setSearchFormData] = useState([]),
171
+ [dragNodeSlot, setDragNodeSlot] = useState(null),
172
+ [dragNodeIx, setDragNodeIx] = useState(),
173
+ onNodeClick = (item, e) => {
174
+ const
175
+ {
176
+ shiftKey,
177
+ metaKey,
178
+ } = e;
179
+
180
+ if (selectionMode === SELECTION_MODE_MULTI) {
181
+ if (shiftKey) {
182
+ if (isInSelection(item)) {
183
+ removeFromSelection(item);
184
+ } else {
185
+ selectRangeTo(item);
186
+ }
187
+ } else if (metaKey) {
188
+ if (isInSelection(item)) {
189
+ // Already selected
190
+ if (allowToggleSelection) {
191
+ removeFromSelection(item);
192
+ } else {
193
+ // Do nothing.
194
+ }
195
+ } else {
196
+ addToSelection(item);
197
+ }
198
+ } else {
199
+ if (isInSelection(item)) {
200
+ // Already selected
201
+ if (allowToggleSelection) {
202
+ removeFromSelection(item);
203
+ } else {
204
+ // Do nothing.
205
+ }
206
+ } else {
207
+ // select just this one
208
+ setSelection([item]);
209
+ }
210
+ }
211
+ } else {
212
+ // selectionMode is SELECTION_MODE_SINGLE
213
+ let newSelection = selection;
214
+ if (isInSelection(item)) {
215
+ // Already selected
216
+ if (allowToggleSelection) {
217
+ // Create empty selection
218
+ newSelection = [];
219
+ } else {
220
+ // Do nothing.
221
+ }
222
+ } else {
223
+ // Select it alone
224
+ newSelection = [item];
225
+ }
226
+ if (newSelection) {
227
+ setSelection(newSelection);
228
+ }
229
+ }
230
+ },
231
+ onRefresh = () => {
232
+ if (!Repository) {
233
+ return;
234
+ }
235
+ const promise = Repository.reload();
236
+ if (promise) { // Some repository types don't use promises
237
+ promise.then(() => {
238
+ setIsLoading(false);
239
+ forceUpdate();
240
+ });
241
+ }
242
+ },
243
+ getHeaderToolbarItems = () => {
244
+ const
245
+ buttons = [
246
+
247
+ {
248
+ key: 'searchBtn',
249
+ text: 'Search tree',
250
+ handler: onSearchTree,
251
+ icon: MagnifyingGlass,
252
+ isDisabled: false,
253
+ },
254
+ {
255
+ key: 'collapseBtn',
256
+ text: 'Collapse whole tree',
257
+ handler: onCollapseAll,
258
+ icon: Collapse,
259
+ isDisabled: false,
260
+ },
261
+ ];
262
+ if (canNodesReorder) {
263
+ buttons.push({
264
+ key: 'reorderBtn',
265
+ text: 'Reorder tree',
266
+ handler: () => setIsReorderMode(!isReorderMode),
267
+ icon: isReorderMode ? NoReorderRows : ReorderRows,
268
+ isDisabled: false,
269
+ });
270
+ }
271
+ const items = _.map(buttons, getIconFromConfig);
272
+
273
+ items.unshift(<Input // Add text input to beginning of header items
274
+ key="searchTree"
275
+ flex={1}
276
+ placeholder="Search all tree nodes"
277
+ onChangeValue={onSearchTree}
278
+ autoSubmit={false}
279
+ />);
280
+
281
+ return items;
282
+ },
283
+ getFooterToolbarItems = () => {
284
+ return _.map(additionalToolbarButtons, getIconFromConfig);
285
+ },
286
+ getIconFromConfig = (config, ix) => {
287
+ const
288
+ iconButtonProps = {
289
+ _hover: {
290
+ bg: 'trueGray.400',
291
+ },
292
+ mx: 1,
293
+ px: 3,
294
+ },
295
+ iconProps = {
296
+ alignSelf: 'center',
297
+ size: styles.TREE_TOOLBAR_ITEMS_ICON_SIZE,
298
+ h: 20,
299
+ w: 20,
300
+ };
301
+ let {
302
+ key,
303
+ text,
304
+ handler,
305
+ icon = null,
306
+ isDisabled = false,
307
+ } = config;
308
+ if (icon) {
309
+ const thisIconProps = {
310
+ color: isDisabled ? styles.TREE_TOOLBAR_ITEMS_DISABLED_COLOR : styles.TREE_TOOLBAR_ITEMS_COLOR,
311
+ };
312
+ icon = React.cloneElement(icon, {...iconProps, ...thisIconProps});
313
+ }
314
+ return <IconButton
315
+ key={key || ix}
316
+ onPress={handler}
317
+ icon={icon}
318
+ isDisabled={isDisabled}
319
+ tooltip={text}
320
+ {...iconButtonProps}
321
+ />;
322
+ },
323
+ buildTreeNodeDatum = (treeNode) => {
324
+ // Build the data-representation of one node and its children,
325
+ // caching text & icon, keeping track of the state for whole tree
326
+ // renderTreeNode uses this to render the nodes.
327
+ const
328
+ isRoot = treeNode.isRoot,
329
+ isLeaf = !treeNode.hasChildren,
330
+ datum = {
331
+ item: treeNode,
332
+ text: getNodeText(treeNode),
333
+ iconCollapsed: isLeaf ? null : getNodeIcon(treeNode, false),
334
+ iconExpanded: isLeaf ? null : getNodeIcon(treeNode, true),
335
+ iconLeaf: isLeaf ? getNodeIcon(treeNode) : null,
336
+ isExpanded: isRoot, // all non-root treeNodes are not expanded by default
337
+ isVisible: isRoot ? isRootVisible : true,
338
+ children: buildTreeNodeData(treeNode.children), // recursively get data for children
339
+ };
340
+
341
+ return datum;
342
+ },
343
+ buildTreeNodeData = (treeNodes) => {
344
+ const data = [];
345
+ _.each(treeNodes, (item) => {
346
+ data.push(buildTreeNodeDatum(item));
347
+ });
348
+ return data;
349
+ },
350
+ renderTreeNode = (datum) => {
351
+ const item = datum.item;
352
+ if (item.isDestroyed) {
353
+ return null;
354
+ }
355
+ if (!datum.isVisible) {
356
+ return null;
357
+ }
358
+
359
+ let nodeProps = getNodeProps ? getNodeProps(item) : {},
360
+ isSelected = isInSelection(item);
361
+
362
+ return <Pressable
363
+ // {...testProps(Repository ? Repository.schema.name + '-' + item.id : item.id)}
364
+ key={item.hash}
365
+ onPress={(e) => {
366
+ if (e.preventDefault && e.cancelable) {
367
+ e.preventDefault();
368
+ }
369
+ if (isReorderMode) {
370
+ return
371
+ }
372
+ switch (e.detail) {
373
+ case 1: // single click
374
+ onNodeClick(item, e); // sets selection
375
+ break;
376
+ case 2: // double click
377
+ if (!isSelected) { // If a row was already selected when double-clicked, the first click will deselect it,
378
+ onNodeClick(item, e); // so reselect it
379
+ }
380
+ if (onEdit) {
381
+ onEdit();
382
+ }
383
+ break;
384
+ case 3: // triple click
385
+ break;
386
+ default:
387
+ }
388
+ }}
389
+ onLongPress={(e) => {
390
+ if (e.preventDefault && e.cancelable) {
391
+ e.preventDefault();
392
+ }
393
+ if (isReorderMode) {
394
+ return
395
+ }
396
+
397
+ // context menu
398
+ const selection = [item];
399
+ setSelection(selection);
400
+ if (onContextMenu) {
401
+ onContextMenu(item, e, selection, setSelection);
402
+ }
403
+ }}
404
+ flexDirection="row"
405
+ flexGrow={1}
406
+ >
407
+ {({
408
+ isHovered,
409
+ isFocused,
410
+ isPressed,
411
+ }) => {
412
+ let bg = nodeProps.bg || styles.TREE_NODE_BG,
413
+ mixWith;
414
+ if (isSelected) {
415
+ if (showHovers && isHovered) {
416
+ mixWith = styles.TREE_NODE_SELECTED_HOVER_BG;
417
+ } else {
418
+ mixWith = styles.TREE_NODE_SELECTED_BG;
419
+ }
420
+ } else if (showHovers && isHovered) {
421
+ mixWith = styles.TREE_NODE_HOVER_BG;
422
+ }
423
+ if (mixWith) {
424
+ const
425
+ mixWithObj = nbToRgb(mixWith),
426
+ ratio = mixWithObj.alpha ? 1 - mixWithObj.alpha : 0.5;
427
+ bg = colourMixer.blend(bg, ratio, mixWithObj.color);
428
+ }
429
+ let WhichTreeNode = TreeNode,
430
+ rowReorderProps = {};
431
+ if (canNodesReorder && isReorderMode) {
432
+ WhichTreeNode = ReorderableTreeNode;
433
+ rowReorderProps = {
434
+ mode: VERTICAL,
435
+ onDragStart: onNodeReorderDragStart,
436
+ onDrag: onNodeReorderDrag,
437
+ onDragStop: onNodeReorderDragStop,
438
+ proxyParent: treeRef.current?.getScrollableNode().children[0],
439
+ proxyPositionRelativeToParent: true,
440
+ getParentNode: (node) => node.parentElement.parentElement.parentElement,
441
+ getProxy: getReorderProxy,
442
+ };
443
+ }
444
+
445
+ return <WhichTreeNode
446
+ nodeProps={nodeProps}
447
+ bg={bg}
448
+ datum={datum}
449
+ onToggle={onToggle}
450
+
451
+ // fields={fields}
452
+ {...rowReorderProps}
453
+ />;
454
+ }}
455
+ </Pressable>;
456
+ },
457
+ renderTreeNodes = (data) => {
458
+ const nodes = [];
459
+ _.each(data, (datum) => {
460
+ nodes.push(renderTreeNode(datum));
461
+ });
462
+ return nodes;
463
+ },
464
+ renderAllTreeNodes = () => {
465
+ const nodes = [];
466
+ _.each(treeNodeData, (datum) => {
467
+ const node = renderTreeNode(datum);
468
+ if (_.isEmpty(node)) {
469
+ return;
470
+ }
471
+
472
+ nodes.push(node);
473
+
474
+ if (_.isEmpty(datum.children)) {
475
+ return;
476
+ }
477
+
478
+ const children = renderTreeNodes(datum.children);
479
+ if (_.isEmpty(children)) {
480
+ return;
481
+ }
482
+
483
+ nodes.concat(children);
484
+ });
485
+ return nodes;
486
+ },
487
+
488
+ // Button handlers
489
+ onToggle = (datum) => {
490
+ datum.isExpanded = !datum.isExpanded;
491
+ forceUpdate();
492
+
493
+ if (datum.item?.repository.isRemote && datum.item.hasChildren && !datum.item.isChildrenLoaded) {
494
+ loadChildren(datum, 1);
495
+ }
496
+ },
497
+ loadChildren = async (datum, depth) => {
498
+ // Helper for onToggle
499
+
500
+ // TODO: Flesh this out
501
+ // Show loading indicator (red bar at top? Spinner underneath current node?)
502
+
503
+
504
+ // Calls getAdditionalParams(), then submits to server
505
+ // Server returns this for each node:
506
+ // Build up treeNodeData for just these new nodes
507
+
508
+
509
+ // Hide loading indicator
510
+
511
+ },
512
+ onCollapseAll = (setNewTreeNodeData = true) => {
513
+ // Go through whole tree and collapse all nodes
514
+ const newTreeNodeData = _.clone(treeNodeData);
515
+
516
+ // Recursive method to collapse all children
517
+ function collapseChildren(children) {
518
+ _.each(children, (child) => {
519
+ child.isExpanded = true;
520
+ if (!_.isEmpty(child.children)) {
521
+ collapseChildren(child.children);
522
+ }
523
+ });
524
+ }
525
+
526
+ collapseChildren(newTreeNodeData);
527
+
528
+ if (setNewTreeNodeData) {
529
+ setTreeNodeData(newTreeNodeData);
530
+ }
531
+ return newTreeNodeData;
532
+ },
533
+ onSearchTree = async (value) => {
534
+
535
+ let found = [];
536
+ if (Repository?.isRemote) {
537
+ // Search tree on server
538
+ found = await Repository.searchTree(value);
539
+ } else {
540
+ // Search local tree data
541
+ found = findTreeNodesByText(value);
542
+ }
543
+
544
+
545
+ const isMultipleHits = found.length > 1;
546
+ let path = '';
547
+ let searchFormData = [];
548
+
549
+ if (Repository?.isRemote) {
550
+ if (isMultipleHits) {
551
+ // 'found' is the results from the server. Use these to show the modal and choose which node you want to select
552
+
553
+
554
+
555
+
556
+ } else {
557
+ // Search local tree data
558
+ found = findTreeNodesByText(value);
559
+ }
560
+
561
+ // TODO: create searchFormData based on 'found' array
562
+
563
+
564
+
565
+
566
+
567
+ setSearchFormData(searchFormData);
568
+ setIsSearchModalShown(true);
569
+
570
+ } else {
571
+ // Expand that one path immediately
572
+ expandPath(path);
573
+ }
574
+ },
575
+ findTreeNodesByText = (text) => {
576
+ // Helper for onSearchTree
577
+ // Searches whole treeNodeData for any matching items
578
+ // Returns multiple nodes
579
+
580
+ const regex = new RegExp(text, 'i'); // instead of matching based on full text match, search for a partial match
581
+
582
+ function searchChildren(children, found = []) {
583
+ _.each(children, (child) => {
584
+ if (child.text.match(regex)) {
585
+ found.push(child);
586
+ }
587
+ if (child.children) {
588
+ searchChildren(child.children, found);
589
+ }
590
+ });
591
+ return found;
592
+ }
593
+ return searchChildren(treeNodeData);
594
+ },
595
+ getTreeNodeByNodeId = (node_id) => {
596
+ if (Repository) {
597
+ return Repository.getById(node_id);
598
+ }
599
+ return data[node_id]; // TODO: This is probably not right!
600
+ },
601
+ getPathByTreeNode = (treeNode) => {
602
+
603
+ ///////// THIS DOESN'T WORK YET /////////
604
+
605
+ function searchChildren(children, currentPath = []) {
606
+ let found = [];
607
+ _.each(children, (child) => {
608
+ const
609
+ item = child.item,
610
+ id = idField ? item[idField] : item.id;
611
+ if (child.text.match(regex)) {
612
+ found.push(child);
613
+ return false;
614
+ }
615
+ if (child.children) {
616
+ const childrenFound = searchChildren(child.children, [...currentPath, id]);
617
+ if (!_.isEmpty(childrenFound)) {
618
+ return false;
619
+ }
620
+ }
621
+ });
622
+ return found;
623
+ }
624
+ const nodes = searchChildren(treeNodeData);
625
+ return nodes.join('/');
626
+
627
+ },
628
+ expandPath = (path) => {
629
+ // Helper for onSearchTree
630
+
631
+ // Drills down the tree based on path (usually given by server).
632
+ // Path would be a list of sequential IDs (3/35/263/1024)
633
+ // Initially, it closes thw whole tree.
634
+
635
+ let newTreeNodeData = collapseAll(false); // false = don't set new treeNodeData
636
+
637
+ // As it navigates down, it will expand the appropriate branches,
638
+ // and then finally highlight & select the node in question
639
+ let pathParts,
640
+ id,
641
+ currentLevelData = newTreeNodeData,
642
+ currentDatum,
643
+ currentNode;
644
+
645
+ while(path.length) {
646
+ pathParts = path.split('/');
647
+ id = parseInt(pathParts[0], 10); // grab the first part of the path
648
+
649
+ // find match in current level
650
+ currentDatum = _.find(currentLevelData, (treeNodeDatum) => {
651
+ return treeNodeDatum.item.id === id;
652
+ });
653
+
654
+ currentNode = currentDatum.item;
655
+
656
+ // THE MAGIC!
657
+ currentDatum.isExpanded = true;
658
+
659
+ path = pathParts.slice(1).join('/'); // put the rest of it back together
660
+ currentLevelData = currentDatum.children;
661
+ }
662
+
663
+ setSelection([currentNode]);
664
+ scrollToNode(currentNode);
665
+ highlightNode(currentNode);
666
+
667
+ setTreeNodeData(newTreeNodeData);
668
+ },
669
+ scrollToNode = (node) => {
670
+ // Helper for expandPath
671
+ // Scroll the tree so the given node is in view
672
+
673
+ // TODO: This will probably need different methods in web and mobile
674
+
675
+
676
+ },
677
+ highlightNode = (node) => {
678
+ // Helper for expandPath
679
+ // Show a brief highlight animation to draw attention to the node
680
+
681
+ // TODO: This will probably need different methods in web and mobile
682
+ // react-highlight for web?
683
+
684
+
685
+ },
686
+
687
+ // Drag/Drop
688
+ getReorderProxy = (node) => {
689
+ const
690
+ row = node.parentElement.parentElement,
691
+ rowRect = row.getBoundingClientRect(),
692
+ parent = row.parentElement,
693
+ parentRect = parent.getBoundingClientRect(),
694
+ proxy = row.cloneNode(true),
695
+ top = rowRect.top - parentRect.top,
696
+ dragNodeIx = Array.from(parent.children).indexOf(row)
697
+
698
+ setDragNodeIx(dragNodeIx); // the ix of which record is being dragged
699
+
700
+ proxy.style.top = top + 'px';
701
+ proxy.style.left = '20px';
702
+ proxy.style.height = rowRect.height + 'px';
703
+ proxy.style.width = rowRect.width + 'px';
704
+ proxy.style.display = 'flex';
705
+ // proxy.style.backgroundColor = '#ccc';
706
+ proxy.style.position = 'absolute';
707
+ proxy.style.border = '1px solid #000';
708
+ return proxy;
709
+ },
710
+ onNodeReorderDragStart = (info, e, proxy, node) => {
711
+ // console.log('onNodeReorderDragStart', info, e, proxy, node);
712
+ const
713
+ proxyRect = proxy.getBoundingClientRect(),
714
+ row = node.parentElement.parentElement,
715
+ parent = row.parentElement,
716
+ parentRect = parent.getBoundingClientRect(),
717
+ rows = _.filter(row.parentElement.children, (childNode) => {
718
+ return childNode.getBoundingClientRect().height !== 0; // Skip zero-height children
719
+ }),
720
+ currentY = proxyRect.top - parentRect.top, // top position of pointer, relative to page
721
+ headerNodeIx = showHeaders ? 0 : null,
722
+ firstActualNodeIx = showHeaders ? 1 : 0;
723
+
724
+ // Figure out which index the user wants
725
+ let newIx = 0;
726
+ _.each(rows, (child, ix, all) => {
727
+ const
728
+ rect = child.getBoundingClientRect(), // rect of the row of this iteration
729
+ {
730
+ top,
731
+ bottom,
732
+ height,
733
+ } = rect,
734
+ compensatedTop = top - parentRect.top,
735
+ compensatedBottom = bottom - parentRect.top,
736
+ halfHeight = height / 2;
737
+
738
+ if (ix === headerNodeIx || child === proxy) {
739
+ return;
740
+ }
741
+ if (ix === firstActualNodeIx) {
742
+ // first row
743
+ if (currentY < compensatedTop + halfHeight) {
744
+ newIx = firstActualNodeIx;
745
+ return false;
746
+ } else if (currentY < compensatedBottom) {
747
+ newIx = firstActualNodeIx + 1;
748
+ return false;
749
+ }
750
+ return;
751
+ } else if (ix === all.length -1) {
752
+ // last row
753
+ if (currentY < compensatedTop + halfHeight) {
754
+ newIx = ix;
755
+ return false;
756
+ }
757
+ newIx = ix +1;
758
+ return false;
759
+ }
760
+
761
+ // all other rows
762
+ if (compensatedTop <= currentY && currentY < compensatedTop + halfHeight) {
763
+ newIx = ix;
764
+ return false;
765
+ } else if (currentY < compensatedBottom) {
766
+ newIx = ix +1;
767
+ return false;
768
+ }
769
+ });
770
+
771
+ let useBottom = false;
772
+ if (!rows[newIx] || rows[newIx] === proxy) {
773
+ newIx--;
774
+ useBottom = true;
775
+ }
776
+
777
+ // Render marker showing destination location
778
+ const
779
+ rowContainerRect = rows[newIx].getBoundingClientRect(),
780
+ top = (useBottom ? rowContainerRect.bottom : rowContainerRect.top) - parentRect.top - parseInt(parent.style.borderWidth), // get relative Y position
781
+ treeNodesContainer = treeRef.current._listRef._scrollRef.childNodes[0],
782
+ treeNodesContainerRect = treeNodesContainer.getBoundingClientRect(),
783
+ marker = document.createElement('div');
784
+
785
+ marker.style.position = 'absolute';
786
+ marker.style.top = top -4 + 'px'; // -4 so it's always visible
787
+ marker.style.height = '4px';
788
+ marker.style.width = treeNodesContainerRect.width + 'px';
789
+ marker.style.backgroundColor = '#f00';
790
+
791
+ treeNodesContainer.appendChild(marker);
792
+
793
+ setDragNodeSlot({ ix: newIx, marker, useBottom, });
794
+ },
795
+ onNodeReorderDrag = (info, e, proxy, node) => {
796
+ // console.log('onNodeReorderDrag', info, e, proxy, node);
797
+ const
798
+ proxyRect = proxy.getBoundingClientRect(),
799
+ row = node.parentElement.parentElement,
800
+ parent = row.parentElement,
801
+ parentRect = parent.getBoundingClientRect(),
802
+ rows = _.filter(row.parentElement.children, (childNode) => {
803
+ return childNode.getBoundingClientRect().height !== 0; // Skip zero-height children
804
+ }),
805
+ currentY = proxyRect.top - parentRect.top, // top position of pointer, relative to page
806
+ headerNodeIx = showHeaders ? 0 : null,
807
+ firstActualNodeIx = showHeaders ? 1 : 0;
808
+
809
+ // Figure out which index the user wants
810
+ let newIx = 0;
811
+ _.each(rows, (child, ix, all) => {
812
+ const
813
+ rect = child.getBoundingClientRect(), // rect of the row of this iteration
814
+ {
815
+ top,
816
+ bottom,
817
+ height,
818
+ } = rect,
819
+ compensatedTop = top - parentRect.top,
820
+ compensatedBottom = bottom - parentRect.top,
821
+ halfHeight = height / 2;
822
+
823
+ if (ix === headerNodeIx || child === proxy) {
824
+ return;
825
+ }
826
+ if (ix === firstActualNodeIx) {
827
+ // first row
828
+ if (currentY < compensatedTop + halfHeight) {
829
+ newIx = firstActualNodeIx;
830
+ return false;
831
+ } else if (currentY < compensatedBottom) {
832
+ newIx = firstActualNodeIx + 1;
833
+ return false;
834
+ }
835
+ return;
836
+ } else if (ix === all.length -1) {
837
+ // last row
838
+ if (currentY < compensatedTop + halfHeight) {
839
+ newIx = ix;
840
+ return false;
841
+ }
842
+ newIx = ix +1;
843
+ return false;
844
+ }
845
+
846
+ // all other rows
847
+ if (compensatedTop <= currentY && currentY < compensatedTop + halfHeight) {
848
+ newIx = ix;
849
+ return false;
850
+ } else if (currentY < compensatedBottom) {
851
+ newIx = ix +1;
852
+ return false;
853
+ }
854
+ });
855
+
856
+ let useBottom = false;
857
+ if (!rows[newIx] || rows[newIx] === proxy) {
858
+ newIx--;
859
+ useBottom = true;
860
+ }
861
+
862
+ // Render marker showing destination location (can't use regular render cycle because this div is absolutely positioned on page)
863
+ const
864
+ rowContainerRect = rows[newIx].getBoundingClientRect(),
865
+ top = (useBottom ? rowContainerRect.bottom : rowContainerRect.top) - parentRect.top - parseInt(parent.style.borderWidth); // get relative Y position
866
+ let marker = dragNodeSlot && dragNodeSlot.marker;
867
+ if (marker) {
868
+ marker.style.top = top -4 + 'px'; // -4 so it's always visible
869
+ }
870
+
871
+ setDragNodeSlot({ ix: newIx, marker, useBottom, });
872
+ // console.log('onNodeReorderDrag', newIx);
873
+
874
+ },
875
+ onNodeReorderDragStop = (delta, e, config) => {
876
+ // console.log('onNodeReorderDragStop', delta, e, config);
877
+ const
878
+ dropIx = dragNodeSlot.ix,
879
+ compensatedDragIx = showHeaders ? dragNodeIx -1 : dragNodeIx, // ix, without taking header row into account
880
+ compensatedDropIx = showHeaders ? dropIx -1 : dropIx, // // ix, without taking header row into account
881
+ dropPosition = dragNodeSlot.useBottom ? DROP_POSITION_AFTER : DROP_POSITION_BEFORE;
882
+
883
+ let shouldMove = true,
884
+ finalDropIx = compensatedDropIx;
885
+
886
+ if (dropPosition === DROP_POSITION_BEFORE) {
887
+ if (dragNodeIx === dropIx || dragNodeIx === dropIx -1) { // basically before or after the drag row's origin
888
+ // Same as origin; don't do anything
889
+ shouldMove = false;
890
+ } else {
891
+ // Actually move it
892
+ if (!Repository) { // If we're just going to be switching rows, rather than telling server to reorder rows, so maybe adjust finalDropIx...
893
+ if (finalDropIx > compensatedDragIx) { // if we're dropping *before* the origin ix
894
+ finalDropIx = finalDropIx -1; // Because we're using BEFORE, we want to switch with the row *prior to* the ix we're dropping before
895
+ }
896
+ }
897
+ }
898
+ } else if (dropPosition === DROP_POSITION_AFTER) {
899
+ // Only happens on the very last row. Everything else is BEFORE...
900
+ if (dragNodeIx === dropIx) {
901
+ // Same as origin; don't do anything
902
+ shouldMove = false;
903
+ }
904
+ }
905
+
906
+ if (shouldMove) {
907
+ // Update the row with the new ix
908
+ let dragRecord,
909
+ dropRecord;
910
+ if (Repository) {
911
+ dragRecord = Repository.getByIx(compensatedDragIx);
912
+ dropRecord = Repository.getByIx(finalDropIx);
913
+
914
+ Repository.reorder(dragRecord, dropRecord, dropPosition);
915
+
916
+ } else {
917
+ function arrayMove(arr, fromIndex, toIndex) {
918
+ var element = arr[fromIndex];
919
+ arr.splice(fromIndex, 1);
920
+ arr.splice(toIndex, 0, element);
921
+ }
922
+ arrayMove(data, compensatedDragIx, finalDropIx);
923
+ }
924
+ }
925
+
926
+ if (dragNodeSlot) {
927
+ dragNodeSlot.marker.remove();
928
+ }
929
+ setDragNodeSlot(null);
930
+ };
931
+
932
+ useEffect(() => {
933
+
934
+ async function buildAndSetTreeNodeData() {
935
+
936
+ let rootNodes;
937
+ if (Repository) {
938
+ rootNodes = await Repository.getRootNodes(true, 1, getAdditionalParams);
939
+ } else {
940
+ // TODO: Make this work for data array
941
+
942
+ }
943
+
944
+ const treeNodeData = buildTreeNodeData(rootNodes);
945
+ setTreeNodeData(treeNodeData);
946
+ }
947
+
948
+ if (!isReady) {
949
+ (async () => {
950
+ await buildAndSetTreeNodeData();
951
+ setIsReady(true);
952
+ })();
953
+ }
954
+
955
+ if (!Repository) {
956
+ return () => {};
957
+ }
958
+
959
+ // set up @onehat/data repository
960
+ const
961
+ setTrue = () => setIsLoading(true),
962
+ setFalse = () => setIsLoading(false),
963
+ onChangeFilters = () => {
964
+ if (!Repository.isAutoLoad) {
965
+ Repository.reload();
966
+ }
967
+ },
968
+ onChangeSorters = () => {
969
+ if (!Repository.isAutoLoad) {
970
+ Repository.reload();
971
+ }
972
+ };
973
+
974
+ Repository.on('beforeLoad', setTrue);
975
+ Repository.on('load', setFalse);
976
+ Repository.ons(['changePage', 'changePageSize',], deselectAll);
977
+ Repository.ons(['changeData', 'change'], buildAndSetTreeNodeData);
978
+ Repository.on('changeFilters', onChangeFilters);
979
+ Repository.on('changeSorters', onChangeSorters);
980
+
981
+
982
+ return () => {
983
+ Repository.off('beforeLoad', setTrue);
984
+ Repository.off('load', setFalse);
985
+ Repository.offs(['changePage', 'changePageSize',], deselectAll);
986
+ Repository.offs(['changeData', 'change'], buildAndSetTreeNodeData);
987
+ Repository.off('changeFilters', onChangeFilters);
988
+ Repository.off('changeSorters', onChangeSorters);
989
+ };
990
+ }, []);
991
+
992
+ useEffect(() => {
993
+ if (!Repository) {
994
+ return () => {};
995
+ }
996
+ if (!disableSelectorSelected && selectorId) {
997
+ let id = selectorSelected?.id;
998
+ if (_.isEmpty(selectorSelected)) {
999
+ id = noSelectorMeansNoResults ? 'NO_MATCHES' : null;
1000
+ }
1001
+ Repository.filter(selectorId, id, false); // so it doesn't clear existing filters
1002
+ }
1003
+
1004
+ }, [selectorId, selectorSelected]);
1005
+
1006
+ const
1007
+ headerToolbarItemComponents = useMemo(() => getHeaderToolbarItems(), []),
1008
+ footerToolbarItemComponents = useMemo(() => getFooterToolbarItems(), [additionalToolbarButtons, isReorderMode]);
1009
+
1010
+ if (!isReady) {
1011
+ return null;
1012
+ }
1013
+
1014
+ // Actual TreeNodes
1015
+ const treeNodes = renderAllTreeNodes();
1016
+
1017
+ // headers & footers
1018
+ let treeFooterComponent = null;
1019
+ if (!disableBottomToolbar) {
1020
+ if (Repository && bottomToolbar === 'pagination' && !disablePagination && Repository.isPaginated) {
1021
+ treeFooterComponent = <PaginationToolbar Repository={Repository} toolbarItems={footerToolbarItemComponents} />;
1022
+ } else if (footerToolbarItemComponents.length) {
1023
+ treeFooterComponent = <Toolbar>{footerToolbarItemComponents}</Toolbar>;
1024
+ }
1025
+ }
1026
+
1027
+ return <>
1028
+ <Column
1029
+ {...testProps('Tree')}
1030
+ flex={1}
1031
+ w="100%"
1032
+ >
1033
+ {topToolbar}
1034
+ {headerToolbarItemComponents}
1035
+
1036
+ <Column w="100%" flex={1} borderTopWidth={isLoading ? 2 : 1} borderTopColor={isLoading ? '#f00' : 'trueGray.300'} onClick={() => {
1037
+ if (!isReorderMode) {
1038
+ deselectAll();
1039
+ }
1040
+ }}>
1041
+ {!treeNodes.length ? <NoRecordsFound text={noneFoundText} onRefresh={onRefresh} /> :
1042
+ treeNodes}
1043
+ </Column>
1044
+
1045
+ {treeFooterComponent}
1046
+ </Column>
1047
+
1048
+ <Modal
1049
+ isOpen={isSearchModalShown}
1050
+ onClose={() => setIsSearchModalShown(false)}
1051
+ >
1052
+ <Column bg="#fff" w={500}>
1053
+ <FormPanel
1054
+ title="Choose Tree Node"
1055
+ instructions="Multiple tree nodes matched your search. Please select which one to show."
1056
+ flex={1}
1057
+ items={[
1058
+ {
1059
+ type: 'Column',
1060
+ flex: 1,
1061
+ items: [
1062
+ {
1063
+ key: 'node_id',
1064
+ name: 'node_id',
1065
+ type: 'Combo',
1066
+ label: 'Tree Node',
1067
+ data: searchFormData,
1068
+ }
1069
+ ],
1070
+ },
1071
+ ]}
1072
+ onCancel={(e) => {
1073
+ // Just close the modal
1074
+ setIsSearchModalShown(false);
1075
+ }}
1076
+ onSave={(data, e) => {
1077
+
1078
+ const node_id = data.node_id; // NOT SURE THIS IS CORRECT!
1079
+
1080
+ if (isMultipleHits) {
1081
+ // Tell the server which one you want and get it, loading all children necessary to get there
1082
+
1083
+
1084
+
1085
+ } else {
1086
+ // Show the path based on local data
1087
+ const
1088
+ treeNode = getTreeNodeByNodeId(node_id),
1089
+ path = getPathByTreeNode(treeNode);
1090
+ expandPath(path);
1091
+ }
1092
+
1093
+ // Close the modal
1094
+ setIsSearchModalShown(false);
1095
+ }}
1096
+ />
1097
+ </Column>
1098
+ </Modal>
1099
+ </>;
1100
+
1101
+ }
1102
+
1103
+ export const SideTreeEditor = withAlert(
1104
+ withEvents(
1105
+ withData(
1106
+ // withMultiSelection(
1107
+ withSelection(
1108
+ withSideEditor(
1109
+ withFilters(
1110
+ withPresetButtons(
1111
+ withContextMenu(
1112
+ Tree
1113
+ )
1114
+ )
1115
+ )
1116
+ )
1117
+ )
1118
+ // )
1119
+ )
1120
+ )
1121
+ );
1122
+
1123
+ export const WindowedTreeEditor = withAlert(
1124
+ withEvents(
1125
+ withData(
1126
+ // withMultiSelection(
1127
+ withSelection(
1128
+ withWindowedEditor(
1129
+ withFilters(
1130
+ withPresetButtons(
1131
+ withContextMenu(
1132
+ Tree
1133
+ )
1134
+ )
1135
+ )
1136
+ )
1137
+ )
1138
+ // )
1139
+ )
1140
+ )
1141
+ );
1142
+
1143
+ export default WindowedTreeEditor;