@onehat/ui
Version:
Base UI for OneHat apps
1,658 lines (1,497 loc) • 49.2 kB
JavaScript
import { useState, useEffect, useRef, useMemo, } from 'react';
import {
HStack,
Pressable,
ScrollView,
Text,
VStack,
VStackNative,
} from '@project-components/Gluestack';
import clsx from 'clsx';
import {
SELECTION_MODE_SINGLE,
SELECTION_MODE_MULTI,
} from '../../Constants/Selection.js';
import {
EDIT,
} from '../../Constants/Commands.js';
import {
VERTICAL,
} from '../../Constants/Directions.js';
import {
COLLAPSED,
EXPANDED,
LEAF,
} from '../../Constants/Tree.js';
import {
UI_MODE_WEB,
UI_MODE_NATIVE,
CURRENT_MODE,
} from '../../Constants/UiModes.js';
import UiGlobals from '../../UiGlobals.js';
import useForceUpdate from '../../Hooks/useForceUpdate.js';
import withContextMenu from '../Hoc/withContextMenu.js';
import withAlert from '../Hoc/withAlert.js';
import withComponent from '../Hoc/withComponent.js';
import withData from '../Hoc/withData.js';
import { withDropTarget } from '../Hoc/withDnd.js';
import withEvents from '../Hoc/withEvents.js';
import withSideEditor from '../Hoc/withSideEditor.js';
import withFilters from '../Hoc/withFilters.js';
import withModal from '../Hoc/withModal.js';
import withMultiSelection from '../Hoc/withMultiSelection.js';
import withPresetButtons from '../Hoc/withPresetButtons.js';
import withPermissions from '../Hoc/withPermissions.js';
import withSelection from '../Hoc/withSelection.js';
import withWindowedEditor from '../Hoc/withWindowedEditor.js';
import getIconButtonFromConfig from '../../Functions/getIconButtonFromConfig.js';
import inArray from '../../Functions/inArray.js';
import testProps from '../../Functions/testProps.js';
import CenterBox from '../Layout/CenterBox.js';
import ReloadButton from '../Buttons/ReloadButton.js';
import TreeNode, { DragSourceDropTargetTreeNode, DragSourceTreeNode, DropTargetTreeNode } from './TreeNode.js';
import FormPanel from '../Panel/FormPanel.js';
import Input from '../Form/Field/Input.js';
import Xmark from '../Icons/Xmark.js';
import Dot from '../Icons/Dot.js';
import Collapse from '../Icons/Collapse.js';
import Expand from '../Icons/Expand.js';
import Gear from '../Icons/Gear.js';
import MagnifyingGlass from '../Icons/MagnifyingGlass.js';
import PaginationToolbar from '../Toolbar/PaginationToolbar.js';
import NoRecordsFound from '../Grid/NoRecordsFound.js';
import Toolbar from '../Toolbar/Toolbar.js';
import Loading from '../Messages/Loading.js';
import Unauthorized from '../Messages/Unauthorized.js';
import _ from 'lodash';
const
DEPTH_INDENT_PX = 25,
SIMULATED_CLICK = 0,
SINGLE_CLICK = 1,
DOUBLE_CLICK = 2,
TRIPLE_CLICK = 3,
TREE_NODE_INTERNAL = 'TREE_NODE_INTERNAL';
// NOTE: If using TreeComponent with getCustomDragProxy, ensure that <GlobalDragProxy /> exists in App.js
function TreeComponent(props) {
const {
areRootsVisible = true,
autoLoadRootNodes = true,
autoSelectRootNode = false,
extraParams = {}, // Additional params to send with each request ( e.g. { order: 'Categories.name ASC' })
isNodeTextConfigurable = false,
editDisplaySettings, // fn
getNodeText = (item) => { // extracts model/data and decides what the row text should be
if (Repository) {
return item.displayValue;
}
return item[displayIx];
},
getNodeContent = (item) => { // extracts model/data and decides what the row content should be
return null;
},
getDisplayTextFromSearchResults = (item) => {
return item.id
},
getNodeIcon = (item) => {
// TODO: Allow for dynamic props on the icon (e.g. special color for some icons)
return Dot;
},
getNodeProps = (item) => {
return {};
},
fitToContainerWidth = false,
noneFoundText,
disableLoadingIndicator = false,
disableSelectorSelected = false,
hideReloadBtn = false,
showHeaderToolbar = true,
showHovers = true,
showSelectHandle = true,
isNodeSelectable = true,
isNodeHoverable = true,
allowToggleSelection = true, // i.e. single click with no shift key toggles the selection of the node clicked on
allowDeselectAll = true, // allow deselecting all nodes by clicking on empty space in tree
forceSelectionOnCollapse = false, // when true, maintains selection by auto-selecting appropriate nodes when collapse would hide current selection
disableBottomToolbar = false,
bottomToolbar = null,
topToolbar = null,
additionalToolbarButtons = [],
reload = null, // Whenever this value changes after initial render, the tree will reload from scratch
parentIdIx,
initialSelection,
canRecordBeEdited,
onTreeLoad,
onLayout,
selectorId,
selectorSelected,
selectorSelectedField = 'id',
// DND
canNodesMoveInternally = false,
canNodeMoveInternally, // optional fn to customize whether each node can be dragged INternally
canNodeMoveExternally, // optional fn to customize whether each node can be dragged EXternally
canNodeAcceptDrop, // optional fn to customize whether each node can accept a dropped item: (targetItem, draggedItem) => boolean
dragProxyField,
getCustomDragProxy = (item, selection) => { // optional fn to render custom drag preview: (item, selection) => ReactElement
let selectionCount = selection?.length || 1,
displayText = dragProxyField ? item[dragProxyField] : (item.displayValue || 'Selected TreeNode');
return <VStack className="bg-white border border-gray-300 rounded-lg p-3 shadow-lg max-w-[200px]">
<Text className="font-semibold text-gray-800">{displayText}</Text>
{selectionCount > 1 &&
<Text className="text-sm text-gray-600">(+{selectionCount -1} more item{selectionCount > 2 ? 's' : ''})</Text>
}
</VStack>;
},
dragPreviewOptions, // optional object for drag preview positioning options
areNodesDragSource = false,
nodeDragSourceType,
getNodeDragSourceItem,
areNodesDropTarget = false,
dropTargetAccept,
onNodeDrop,
// withComponent
self,
// withAlert
alert,
confirm,
showInfo,
// withModal
showModal,
hideModal,
// withEditor
onAdd,
onEdit,
onDelete,
onView,
onDuplicate,
onReset,
onContextMenu,
setWithEditListeners,
// withData
Repository,
data,
fields,
idField,
displayField,
idIx,
displayIx,
// withPermissions
canUser,
// withDnd
isDropTarget,
canDrop,
isOver,
dropTargetRef,
// withSelection
selection,
setSelection,
selectionMode,
removeFromSelection,
addToSelection,
deselectAll,
selectRangeTo,
isInSelection,
noSelectorMeansNoResults = false,
disableSelectionChanges,
enableSelectionChanges,
refreshSelection,
} = props,
forceUpdate = useForceUpdate(),
treeRef = useRef(),
treeNodeData = useRef(),
dragSelectionRef = useRef([]),
[isReady, setIsReady] = useState(false),
[isLoading, setIsLoading] = useState(false),
[searchResults, setSearchResults] = useState([]),
[searchFormData, setSearchFormData] = useState([]),
[highlitedDatum, setHighlitedDatum] = useState(null),
[treeSearchValue, setTreeSearchValue] = useState(''),
showNodeHandle = showSelectHandle || areNodesDragSource,
// state getters & setters
getTreeNodeData = () => {
return treeNodeData.current;
},
setTreeNodeData = (tnd) => {
treeNodeData.current = tnd;
forceUpdate();
},
// event handers
onNodeClick = (item, e) => {
if (!setSelection) {
return;
}
const {
shiftKey,
metaKey,
} = e;
if (selectionMode === SELECTION_MODE_MULTI) {
if (shiftKey) {
if (isInSelection(item)) {
removeFromSelection(item);
} else {
selectRangeTo(item);
}
} else if (metaKey) {
if (isInSelection(item)) {
// Already selected
if (allowToggleSelection) {
removeFromSelection(item);
} else {
// Do nothing.
}
} else {
addToSelection(item);
}
} else {
if (isInSelection(item)) {
// Already selected
if (allowToggleSelection) {
removeFromSelection(item);
} else {
// Do nothing.
}
} else {
// select just this one
setSelection([item]);
}
}
} else {
// selectionMode is SELECTION_MODE_SINGLE
let newSelection = selection;
if (isInSelection(item)) {
// Already selected
if (allowToggleSelection) {
// Create empty selection
newSelection = [];
} else {
// Do nothing.
}
} else {
// Select it alone
newSelection = [item];
}
if (newSelection) {
setSelection(newSelection);
}
}
},
onBeforeAdd = async () => {
// called during withEditor::doAdd, before the add operation is called
// returning false will cancel the add operation
// Load children before adding the new node
if (_.isEmpty(selection)) {
alert('Please select a parent node first.')
return;
}
const
parent = selection[0],
parentDatum = getDatumById(parent.id);
if (parent.hasChildren && !parent.areChildrenLoaded) {
await loadChildren(parentDatum);
}
// forceUpdate();
},
onAfterAdd = (entity) => {
// called during withEditor::doAdd, after the add operation is called
// Add the entity to the tree, show parent as hasChildren and expanded
const
parent = selection[0],
parentDatum = getDatumById(parent.id);
if (!parent.hasChildren) {
parent.hasChildren = true; // since we're adding a new child
}
if (!parentDatum.isExpanded) {
parentDatum.isExpanded = true;
}
forceUpdate();
},
onAfterAddSave = (entities) => {
// Update the datum with the new entity
return onAfterEdit(entities);
},
onBeforeEditSave = (entities) => {
onBeforeSave(entities);
},
onAfterEdit = async (entities) => {
// Refresh the node's display
const
node = entities[0],
existingDatum = getDatumById(node.id), // TODO: Make this work for >1 entity
newDatum = buildTreeNodeDatum(node);
// copy the updated data to existingDatum
_.assign(existingDatum, newDatum);
existingDatum.isLoading = false;
if (node.parent?.id) {
const
existingParentDatum = getDatumById(node.parent.id),
newParentDatum = buildTreeNodeDatum(node.parent);
_.assign(existingParentDatum, newParentDatum);
existingParentDatum.isExpanded = true;
}
forceUpdate();
},
onBeforeDeleteSave = (entities) => {
onBeforeSave(entities);
},
onBeforeSave = (entities) => {
const
node = entities[0],
datum = getDatumById(node.id); // TODO: Make this work for >1 entity
datum.isLoading = true;
forceUpdate();
},
onAfterDelete = async (entities) => {
const parent = entities[0].parent;
if (parent) {
await reloadNode(parent);
}
},
onToggle = async (datum, e) => {
if (datum.isLoading) {
return;
}
const
isExpanded = !datum.isExpanded, // sets new state
isShiftKey = e.shiftKey; // hold down the shift key to load all children
datum.isExpanded = isExpanded;
if (isExpanded) {
// opening
if (datum.item.repository?.isRemote && datum.item.hasChildren) {
if (isShiftKey) {
// load ALL children
await loadChildren(datum, 'all');
return;
} else if (!datum.item.areChildrenLoaded) {
// load only one level
await loadChildren(datum, 1);
return;
}
}
} else {
// closing
if (datumContainsSelection(datum)) {
if (forceSelectionOnCollapse) {
// Select the node being collapsed instead of deselecting all
setSelection([datum.item]);
} else {
deselectAll();
}
}
}
forceUpdate();
},
onCollapseAll = () => {
const newTreeNodeData = [...getTreeNodeData()];
// Check if current selection will be hidden after collapse
let willSelectionBeHidden = false;
if (forceSelectionOnCollapse && selection.length > 0) {
// After collapse, only root nodes will be visible
const rootNodeIds = newTreeNodeData.map(datum => datum.item.id);
willSelectionBeHidden = !selection.some(selectedItem =>
rootNodeIds.includes(selectedItem.id)
);
}
collapseNodes(newTreeNodeData);
setTreeNodeData(newTreeNodeData);
// If selection will be hidden and we have root nodes, select the first root
if (willSelectionBeHidden && newTreeNodeData.length > 0) {
setSelection([newTreeNodeData[0].item]);
}
},
onExpandAll = () => {
confirm('Are you sure you want to expand the whole tree? This may take a while.', async () => {
const newTreeNodeData = [...getTreeNodeData()];
await expandNodes(newTreeNodeData);
setTreeNodeData(newTreeNodeData);
});
},
onSearchTree = async (q) => {
let found = [];
if (q === '') {
setHighlitedDatum(null);
alert('Please enter a search query.');
return;
}
if (Repository?.isRemote) {
// Search tree on server
found = await Repository.searchNodes(q);
} else {
// Search local tree data
found = findTreeNodesByText(q);
}
if (_.isEmpty(found)) {
deselectAll();
setHighlitedDatum(null);
alert('No matches found.');
return;
}
const isMultipleHits = found.length > 1;
if (!isMultipleHits) {
expandPath(found[0].cPath); // highlights and selects the last node in the cPath
return;
}
// Show modal so user can select which node to go to
const searchFormData = [];
_.each(found, (item) => {
searchFormData.push([item.id, getDisplayTextFromSearchResults(item)]);
});
setSearchFormData(searchFormData);
setSearchResults(found);
showChooseTreeNode();
},
showChooseTreeNode = () => {
showModal({
body: <VStack className="bg-white w-[300px]">
<FormPanel
_panel={{
title: 'Choose Tree Node',
}}
instructions="Multiple tree nodes matched your search. Please select which one to show."
_form={{
flex: 1,
items: [
{
type: 'Column',
flex: 1,
items: [
{
key: 'node_id',
name: 'node_id',
type: 'Combo',
label: 'Tree Node',
data: searchFormData,
}
],
},
],
onCancel: (e) => {
setHighlitedDatum(null);
hideModal();
},
onSave: (data, e) => {
const
treeNode = _.find(searchResults, (item) => {
return item.id === data.node_id;
}),
cPath = treeNode.cPath;
expandPath(cPath);
hideModal();
},
}}
/>
</VStack>,
onCancel: hideModal,
});
},
// internal DND
onInternalNodeDrop = async (droppedOn, droppedItem) => {
let selectedNodes = [];
if (droppedItem.getSelection) {
selectedNodes = droppedItem.getSelection();
}
if (_.isEmpty(selectedNodes)) {
selectedNodes = [droppedItem.item];
}
// filter out nodes that would already be moved by others in the selection
const selectedNodesClone = [...selectedNodes];
selectedNodes = selectedNodes.filter((node) => {
let isDescendant = false;
_.each(selectedNodesClone, (otherNode) => {
if (node.id === otherNode.id) {
return false; // skip self
}
isDescendant = isDescendantOf(node, otherNode);
if (isDescendant) {
return false; // found descendant; break loop
}
isDescendant = isDescendantOf(otherNode, node);
if (isDescendant) {
return false; // found ancestor; break loop
}
});
return !isDescendant;
});
const isMultiSelection = selectedNodes.length > 1;
if (isMultiSelection) {
alert('moving multiple disparate nodes not yet implemented');
return;
}
// Check if drop target had no children before the move
const
hadNoChildrenBefore = !droppedOn.hasChildren,
selectedNode = selectedNodes[0],
commonAncestorId = await Repository.moveTreeNode(selectedNode, droppedOn.id);
const commonAncestorDatum = getDatumById(commonAncestorId);
disableSelectionChanges(); // the reloadNode() commands change the selection. We don't want this when dragging and dropping the tree
await reloadNode(commonAncestorDatum.item); // **selectionChange to [] bc child node no longer exists
// If drop target gained its first children, reload it specifically and expand it
if (hadNoChildrenBefore) {
const refreshedDroppedOn = Repository.getById(droppedOn.id);
if (refreshedDroppedOn && refreshedDroppedOn.hasChildren) {
// Reload the drop target to get its new children
await reloadNode(refreshedDroppedOn); // **selectionChange
// Now expand it to show the moved node
const dropTargetDatum = getDatumById(refreshedDroppedOn.id);
if (dropTargetDatum) {
dropTargetDatum.isExpanded = true;
forceUpdate();
}
}
}
enableSelectionChanges();
refreshSelection();
},
// utilities
buildTreeNodeDatum = (treeNode, defaultToExpanded = false) => {
// Build the data-representation of one node and its children,
// caching text & icon, keeping track of the state for whole tree
// renderTreeNode uses this to render the nodes.
const
isRoot = treeNode.isRoot,
children = buildTreeNodeData(treeNode.children, defaultToExpanded), // recursively get data for children
datum = {
item: treeNode,
treeRef,
text: getNodeText(treeNode),
content: getNodeContent ? getNodeContent(treeNode) : null,
icon: getNodeIcon(treeNode),
isExpanded: treeNode.isExpanded || defaultToExpanded || isRoot, // all non-root treeNodes are collapsed by default
isVisible: isRoot ? areRootsVisible : true,
isLoading: false,
children,
};
return datum;
},
buildTreeNodeData = (treeNodes, defaultToExpanded = false) => {
const data = [];
_.each(treeNodes, (item) => {
data.push(buildTreeNodeDatum(item, defaultToExpanded));
});
return data;
},
buildAndSetTreeNodeData = async () => {
let nodes = [];
if (Repository) {
if (!Repository.isDestroyed) {
if (!Repository.isLoaded) {
nodes = await Repository.loadRootNodes(1);
} else {
nodes = Repository.getRootNodes();
}
}
} else {
nodes = assembleDataTreeNodes();
}
const treeNodeData = buildTreeNodeData(nodes);
setTreeNodeData(treeNodeData);
if (onTreeLoad) {
onTreeLoad(self);
}
return treeNodeData;
},
buildAndSetOneTreeNodeData = (entity) => {
if (!entity || !entity.parent) {
// If no parent, it might be a root node, so rebuild the tree
buildAndSetTreeNodeData();
return;
}
const parentDatum = getDatumById(entity.parent.id);
if (!parentDatum) {
// Parent not found in current tree structure, rebuild
buildAndSetTreeNodeData();
return;
}
// Create datum for the new entity and add it to parent's children
const newDatum = buildTreeNodeDatum(entity);
parentDatum.children.push(newDatum);
// Update parent to show it has children and expand if needed
if (!entity.parent.hasChildren) {
entity.parent.hasChildren = true;
}
if (!parentDatum.isExpanded) {
parentDatum.isExpanded = true;
}
forceUpdate();
},
datumContainsSelection = (datum) => {
if (_.isEmpty(selection)) {
return false;
}
const
selectionIds = _.map(selection, (item) => item.id),
datumIds = getDatumChildIds(datum),
intersection = selectionIds.filter(x => datumIds.includes(x));
return !_.isEmpty(intersection);
},
findTreeNodesByText = (text) => {
// Helper for onSearchTree
// Searches whole treeNodeData for any matching items
// Returns multiple nodes
const regex = new RegExp(text, 'i'); // instead of matching based on full text match, search for a partial match
function searchChildren(children, found = []) {
_.each(children, (child) => {
if (child.text.match(regex)) {
found.push(child);
}
if (child.children) {
searchChildren(child.children, found);
}
});
return found;
}
return searchChildren(getTreeNodeData());
},
getTreeNodeByNodeId = (node_id) => {
if (Repository) {
if (!Repository.isDestroyed) {
return Repository.getById(node_id);
}
}
return data[node_id]; // TODO: This is probably not right!
},
getDatumChildIds = (datum) => {
let ids = [];
_.each(datum.children, (childDatum) => {
ids.push(childDatum.item.id);
if (childDatum.children.length) {
const childIds = getDatumChildIds(childDatum);
ids = ids.concat(childIds);
const t = true;
}
});
return ids;
},
getDatumById = (id) => {
let found = null;
function walkTree(datum) {
if (datum.item.id === id) {
found = datum;
return;
}
_.each(datum.children, (child) => {
if (!found) {
walkTree(child);
}
});
}
_.each(getTreeNodeData(), (rootDatum) => {
walkTree(rootDatum);
});
return found;
},
assembleDataTreeNodes = () => {
// Populates the TreeNodes with .parent and .children references
// NOTE: This is only for 'data', not for Repositories!
// 'data' is essentially an Adjacency List, not a ClosureTable.
const clonedData = [...data];
// Reset all parent/child relationships
_.each(clonedData, (treeNode) => {
treeNode.isRoot = !treeNode[parentIdIx];
treeNode.parent = null;
treeNode.children = [];
});
// Rebuild all parent/child relationships
_.each(clonedData, (treeNode) => {
const parent = _.find(clonedData, (tn) => {
return tn[idIx] === treeNode[parentIdIx];
});
if (parent) {
treeNode.parent = parent;
parent.children.push(treeNode);
}
});
// populate calculated fields
const treeNodes = [];
_.each(clonedData, (treeNode) => {
treeNode.hasChildren = !_.isEmpty(treeNode.children);
let parent = treeNode.parent,
i = 0;
while(parent) {
i++;
parent = parent.parent;
}
treeNode.depth = i;
treeNode.hash = treeNode[idIx];
if (treeNode.isRoot) {
treeNodes.push(treeNode);
}
});
return treeNodes;
},
belongsToThisTree = (treeNode) => {
if (!treeNode) {
return false;
}
const datum = getDatumById(treeNode.id);
if (!datum) {
return false;
}
return datum.treeRef === treeRef;
},
isDescendantOf = (potentialDescendant, potentialAncestor) => {
// Check if potentialDescendant is a descendant of potentialAncestor
// by walking up the parent chain from potentialDescendant
let currentTreeNode = potentialDescendant;
while(currentTreeNode) {
if (currentTreeNode.id === potentialAncestor.id) {
return true;
}
currentTreeNode = currentTreeNode.parent;
}
return false;
},
isChildOf = (potentialChild, potentialParent) => {
return potentialChild.parent?.id === potentialParent.id;
},
reloadTree = () => {
Repository.areRootNodesLoaded = false;
return buildAndSetTreeNodeData();
},
reloadNode = async (node) => {
// mark node as loading
const existingDatum = getDatumById(node.id);
existingDatum.isLoading = true;
forceUpdate();
// reload from server
await node.reload();
// Refresh the node's display
const newDatum = buildTreeNodeDatum(node);
// copy the updated data to existingDatum
_.assign(existingDatum, _.omit(newDatum, ['isExpanded']));
existingDatum.isLoading = false;
forceUpdate();
},
loadChildren = async (datum, depth = 1) => {
// Show loading indicator (spinner underneath current node?)
datum.isLoading = true;
forceUpdate();
try {
let defaultToExpanded = false;
if (depth === 'all') {
defaultToExpanded = true;
depth = 9999;
}
const
node = await datum.item.loadChildren(depth),
directChildren = _.filter(node.children, (child) => { // narrow list to only direct descendants, so buildTreeNodeData can work correctly
return child.depth === datum.item.depth + 1;
});
datum.children = buildTreeNodeData(directChildren, defaultToExpanded);
datum.isExpanded = true;
} catch (err) {
// TODO: how do I handle errors?
// Color parent node red
// Modal alert box?
// Inline error msg? I'm concerned about modals not stacking correctly, but if we put it inline, it'll work.
datum.isExpanded = false;
}
// Hide loading indicator
datum.isLoading = false;
forceUpdate();
},
collapseNodes = (nodes) => {
collapseNodesRecursive(nodes);
},
collapseNodesRecursive = (nodes) => {
_.each(nodes, (node) => {
node.isExpanded = false;
if (!_.isEmpty(node.children)) {
collapseNodesRecursive(node.children);
}
});
},
expandNodes = async (nodes) => {
// load all children of nodes
for (const node of nodes) {
await loadChildren(node, 'all');
}
// expand them in UI
expandNodesRecursive(nodes);
},
expandNodesRecursive = (nodes) => {
_.each(nodes, (node) => {
node.isExpanded = true;
if (!_.isEmpty(node.children)) {
expandNodesRecursive(node.children);
}
});
},
expandPath = async (cPath, highlight = true) => {
// First, close the whole tree.
let newTreeNodeData = [...getTreeNodeData()];
collapseNodes(newTreeNodeData);
// As it navigates down, it will expand the appropriate branches,
// and then finally highlight & select the node in question
let cPathParts,
id,
currentLevelData = newTreeNodeData,
currentDatum,
parentDatum,
currentNode;
while(cPath.length) {
cPathParts = cPath.split('/');
id = parseInt(cPathParts[0], 10); // grab the first part of the cPath
// find match in current level
currentDatum = _.find(currentLevelData, (treeNodeDatum) => {
return treeNodeDatum.item.id === id;
});
if (!currentDatum) {
if (!parentDatum) {
currentDatum = currentLevelData[0]; // this is essentially the root node (currentLevelData can contain more than one node, so just set it to the first)
// currentLevelData = currentDatum;
} else {
if (!parentDatum.isLoaded) {
await loadChildren(parentDatum, 1);
}
currentLevelData = parentDatum.children;
}
currentDatum = _.find(currentLevelData, (treeNodeDatum) => {
return treeNodeDatum.item.id === id;
});
}
currentNode = currentDatum.item;
if (!currentDatum.isExpanded) {
await loadChildren(currentDatum, 1);
}
cPath = cPathParts.slice(1).join('/'); // put the rest of it back together
currentLevelData = currentDatum.children;
parentDatum = currentDatum;
}
setSelection([currentNode]);
scrollToNode(currentNode);
if (highlight) {
setHighlitedDatum(currentDatum);
}
setTreeNodeData(newTreeNodeData);
},
scrollToNode = (node) => {
// Helper for expandPath
// Scroll the tree so the given node is in view
// TODO: This will probably need different methods in web and mobile
// From Github Copliot:
// In React, if you want to scroll individual DOM nodes into view, you would typically assign a ref to each of them. However, managing a large number of refs can be cumbersome and may lead to performance issues.
// An alternative approach is to assign a unique id to each DOM node and use the document.getElementById(id).scrollIntoView() method to scroll to a specific node. This way, you don't need to manage a large number of refs.
// Here's an example:
// const MyComponent = () => {
// const scrollTo = (id) => {
// document.getElementById(id).scrollIntoView();
// };
// return (
// <div>
// {Array.from({ length: 100 }).map((_, index) => (
// <div id={`item-${index}`} key={index}>
// Item {index}
// </div>
// ))}
// <button onClick={() => scrollTo('item-50')}>Scroll to item 50</button>
// </div>
// );
// };
// In this example, we're creating 100 divs each with a unique id. We also have a button that, when clicked, scrolls to the div with the id 'item-50'.
// Please note that this approach uses the DOM API directly, which is generally discouraged in React. It's recommended to use refs when you need to interact with DOM nodes directly. However, in cases where you need to manage a large number of DOM nodes, using ids can be a more practical solution.
// Also, keep in mind that document.getElementById(id).scrollIntoView() might not work as expected in all situations, especially in complex layouts or when using certain CSS properties. Always test your code thoroughly to make sure it works as expected.
// ... Not sure how to do this with NativeBase, as I've had trouble assigning IDs
// Maybe I first collapse the tree, then expand just the cPath?
},
// render
getHeaderToolbarItems = () => {
const
buttons = [
// {
// key: 'searchBtn',
// text: 'Search tree',
// handler: () => onSearchTree(treeSearchValue),
// icon: MagnifyingGlass,
// isDisabled: !treeSearchValue.length,
// },
{
key: 'collapseAllBtn',
text: 'Collapse whole tree',
handler: onCollapseAll,
icon: Collapse,
isDisabled: false,
},
{
key: 'expandAllBtn',
text: 'Expand whole tree',
handler: onExpandAll,
icon: Expand,
isDisabled: false,
},
];
if (isNodeTextConfigurable && editDisplaySettings) {
buttons.push({
key: 'editNodeTextBtn',
text: 'Display Settings',
handler: () => editDisplaySettings(),
icon: Gear,
});
}
const items = _.map(buttons, (config, ix) => getIconButtonFromConfig(config, ix, self));
// items.unshift(<Input // Add text input to beginning of header items
// key="searchNodes"
// className="flex-1"
// placeholder="Find tree node"
// onChangeText={(val) => setTreeSearchValue(val)}
// onKeyPress={(e) => {
// if (e.key === 'Enter') {
// onSearchTree(treeSearchValue);
// }
// }}
// value={treeSearchValue}
// autoSubmit={false}
// />);
// if (treeSearchValue.length) {
// // Add 'X' button to clear search
// items.unshift(getIconButtonFromConfig({
// key: 'xBtn',
// handler: () => {
// setHighlitedDatum(null);
// setTreeSearchValue('');
// },
// icon: Xmark,
// }, 0, self));
// }
return items;
},
getFooterToolbarItems = () => {
return _.map(additionalToolbarButtons, (config, ix) => getIconButtonFromConfig(config, ix, self));
},
renderTreeNode = (datum) => {
if (!datum.isVisible) {
return null;
}
const item = datum.item;
if (item.isDestroyed) {
return null;
}
const depth = item.depth;
let nodeProps = getNodeProps ? getNodeProps(item) : {},
isSelected = isInSelection(item);
return <Pressable
{...testProps((Repository ? Repository.schema.name : 'TreeNode') + '-' + item?.id)}
key={item.hash}
onPress={(e) => {
if (e.preventDefault && e.cancelable) {
e.preventDefault();
}
switch (e.detail) {
case SIMULATED_CLICK:
case SINGLE_CLICK:
onNodeClick(item, e); // sets selection
break;
case DOUBLE_CLICK:
if (!isSelected) { // If a row was already selected when double-clicked, the first click will deselect it,
onNodeClick(item, e); // so reselect it
}
if (onEdit) {
if (canUser && !canUser(EDIT)) { // permissions
return;
}
if (canRecordBeEdited && !canRecordBeEdited(selection)) { // record can be edited
return;
}
onEdit();
} else if (onView) {
if (canUser && !canUser(VIEW)) { // permissions
return;
}
onView();
}
break;
case TRIPLE_CLICK:
break;
default:
}
}}
onLongPress={(e) => {
if (e.preventDefault && e.cancelable) {
e.preventDefault();
}
if (!setSelection) {
return;
}
// context menu
const selection = [item];
setSelection(selection);
if (onContextMenu) {
onContextMenu(item, e, selection);
}
}}
onContextMenu={(e) => {
// web only; happens before onLongPress triggers
// different behavior here than onLongPress:
// if user clicks on a phantom record, or if onContextMenu is not set, pass to the browser's context menu
if (selection && selection[0] && selection[0].isRemotePhantom) {
return; // block context menu or changing selection when a remote phantom is already selected
}
if (onContextMenu) {
e.preventDefault();
e.stopPropagation(); // disallow browser's default behavior for context menu
// if the right-clicked item is not in the current selection,
// set the selection only to this one item.
let newSelection = selection;
if (!isInSelection(item)) {
newSelection = [item];
setSelection(newSelection);
}
if (onContextMenu) {
onContextMenu(item, e, newSelection);
}
}
}}
className={clsx(
'Pressable',
'Node',
'flex-row',
)}
style={{
paddingLeft: (areRootsVisible ? depth : depth -1) * DEPTH_INDENT_PX,
}}
>
{({
hovered,
focused,
pressed,
}) => {
const nodeDragProps = {};
let WhichNode = TreeNode,
nodeCanSelect = true,
nodeCanDrag = false;
if (CURRENT_MODE === UI_MODE_WEB) { // DND is currently web-only TODO: implement for RN
dragSelectionRef.current = selection;
const
getSelection = () => dragSelectionRef.current,
userHasPermissionToDrag = (!canUser || canUser(EDIT));
if (userHasPermissionToDrag) {
const
shouldSupportInternalDrag = canNodesMoveInternally,
shouldSupportExternalDrag = areNodesDragSource,
shouldSupportExternalDrop = areNodesDropTarget,
supportedDragTypes = [],
acceptedDropTypes = [];
if (shouldSupportInternalDrag) {
supportedDragTypes.push(TREE_NODE_INTERNAL);
}
if (shouldSupportExternalDrag) {
supportedDragTypes.push(nodeDragSourceType);
}
if (shouldSupportInternalDrag) {
acceptedDropTypes.push(TREE_NODE_INTERNAL);
}
if (shouldSupportExternalDrop) {
acceptedDropTypes.push(dropTargetAccept);
}
// Set up drag source if needed
if (supportedDragTypes.length > 0) {
// If only internal drag is supported, root nodes cannot be dragged
// since they cannot be dropped anywhere valid within the tree
const canDragRootNode = shouldSupportExternalDrag || !shouldSupportInternalDrag;
nodeDragProps.isDragSource = canDragRootNode || !item.isRoot;
// Use the primary drag type (external takes precedence if both are supported)
// This is what react-dnd will use as the main type for drag operations
nodeDragProps.dragSourceType = shouldSupportExternalDrag ? supportedDragTypes.find(type => type !== TREE_NODE_INTERNAL) || supportedDragTypes[0] : supportedDragTypes[0];
// Create unified drag source item
const baseDragSourceItem = {
id: item.id,
item,
getSelection,
isInSelection,
type: nodeDragProps.dragSourceType, // Primary drag type
supportedDragTypes, // Include all supported types
sourceComponentRef: treeRef, // Reference to the originating component
dragContext: {
isInternal: shouldSupportInternalDrag,
isExternal: shouldSupportExternalDrag,
sourceComponent: treeRef
},
onDragStart: () => {
if (!isInSelection(item)) { // get updated isSelected (will be stale if using one in closure)
// reset the selection to just this one node if it's not already selected
setSelection([item]);
}
},
};
// Add external drag properties if needed
if (shouldSupportExternalDrag && getNodeDragSourceItem) {
const externalDragItem = getNodeDragSourceItem(item, getSelection, isInSelection, nodeDragSourceType);
Object.assign(baseDragSourceItem, externalDragItem);
}
nodeDragProps.dragSourceItem = baseDragSourceItem;
// Unified canDrag logic
nodeDragProps.canDrag = (monitor) => {
const currentSelection = getSelection();
// Root nodes can be dragged - restriction is handled in drop validation
// Use custom drag validation if provided
if (shouldSupportInternalDrag && canNodeMoveInternally) {
// In multi-selection, all nodes must be draggable
const internalValid = currentSelection.every(node => canNodeMoveInternally(node));
if (!internalValid) return false;
}
if (shouldSupportExternalDrag && canNodeMoveExternally) {
const externalValid = canNodeMoveExternally(monitor);
if (!externalValid) return false;
}
return true;
};
// Add custom drag preview options
if (dragPreviewOptions) {
nodeDragProps.dragPreviewOptions = dragPreviewOptions;
}
// Add drag preview rendering
nodeDragProps.getDragProxy = getCustomDragProxy ?
(dragItem) => getCustomDragProxy(item, getSelection()) :
null; // let GlobalDragProxy handle the default case
nodeCanDrag = true;
}
// Set up drop target if needed
if (acceptedDropTypes.length > 0) {
nodeDragProps.isDropTarget = true;
nodeDragProps.dropTargetAccept = acceptedDropTypes;
// drop validation
const validateDrop = (draggedItem) => {
if (!draggedItem) {
return false;
}
// Determine if this is an internal drop based on component reference
// If sourceComponentRef is undefined, treat as external drop
const isInternalDrop = draggedItem.sourceComponentRef &&
draggedItem.sourceComponentRef === treeRef;
if (isInternalDrop && shouldSupportInternalDrag) {
// Internal drop validation
const currentSelection = getSelection();
// Always include the dragged item itself in validation
// If no selection exists, the dragged item is what we're moving
const nodesToValidate = currentSelection.length > 0 ? currentSelection : [draggedItem.item];
// Root nodes cannot be moved internally within the same tree
const hasRootNode = nodesToValidate.some(node => node.isRoot);
if (hasRootNode) {
return false;
}
// validate that the dropped item is not already a direct child of the target node
if (isChildOf(draggedItem.item, item)) {
return false;
}
// Validate that none of the nodes being moved can be dropped into the target location
for (const nodeToMove of nodesToValidate) {
if (nodeToMove.id === item.id) {
// Cannot drop a node onto itself
return false;
}
if (isDescendantOf(item, nodeToMove)) {
// Cannot drop a node into its own descendants
return false;
}
}
// Internal drops are allowed if they pass the above validations
return true;
} else {
// External drop validation - use custom business logic
if (canNodeAcceptDrop && typeof canNodeAcceptDrop === 'function') {
return canNodeAcceptDrop(item, draggedItem);
}
// Allow external drops by default if no custom validation is provided
return true;
}
};
nodeDragProps.canDrop = (draggedItem, monitor) => validateDrop(draggedItem); // for React DnD
nodeDragProps.validateDrop = validateDrop; // for visual feedback
// drop handler
nodeDragProps.onDrop = (droppedItem) => {
// Determine if this is an internal drop based on component reference
// If sourceComponentRef is undefined, treat as external drop
const isInternalDrop = droppedItem.sourceComponentRef &&
droppedItem.sourceComponentRef === treeRef;
if (isInternalDrop && shouldSupportInternalDrag) {
onInternalNodeDrop(item, droppedItem);
} else if (shouldSupportExternalDrop && onNodeDrop) {
onNodeDrop(item, droppedItem);
}
};
}
if (nodeDragProps.isDragSource && nodeDragProps.isDropTarget) {
WhichNode = DragSourceDropTargetTreeNode;
} else if (nodeDragProps.isDragSource) {
WhichNode = DragSourceTreeNode;
} else if (nodeDragProps.isDropTarget) {
WhichNode = DropTargetTreeNode;
}
}
}
return <WhichNode
datum={datum}
nodeProps={nodeProps}
onToggle={onToggle}
isNodeSelectable={isNodeSelectable}
isNodeHoverable={isNodeHoverable}
isSelected={isSelected}
isHovered={hovered}
showHovers={showHovers}
showNodeHandle={showNodeHandle}
nodeCanSelect={nodeCanSelect}
nodeCanDrag={nodeCanDrag}
isHighlighted={highlitedDatum === datum}
fitToContainerWidth={fitToContainerWidth}
{...nodeDragProps}
// fields={fields}
/>;
}}
</Pressable>;
},
renderTreeNodes = (data) => {
let nodes = [];
_.each(data, (datum) => {
const node = renderTreeNode(datum);
if (!node) {
return;
}
nodes.push(node);
if (datum.children.length && datum.isExpanded) {
const childTreeNodes = renderTreeNodes(datum.children); // recursion
nodes = nodes.concat(childTreeNodes);
}
});
return nodes;
};
useEffect(() => {
if (!isReady) {
return () => {};
}
reloadTree();
}, [reload]);
useEffect(() => {
if (!Repository) {
(async () => {
await buildAndSetTreeNodeData();
setIsReady(true);
})();
return () => {};
}
// set up @onehat/data repository
const
setTrue = () => setIsLoading(true),
setFalse = () => setIsLoading(false);
if (Repository.isLoading) {
setTrue();
}
Repository.on('beforeLoad', setTrue);
Repository.on('load', setFalse);
Repository.on('loadRootNodes', setFalse);
Repository.on('loadRootNodes', buildAndSetTreeNodeData);
Repository.on('add', buildAndSetOneTreeNodeData);
Repository.on('changeFilters', reloadTree);
Repository.on('changeSorters', reloadTree);
(async () => {
if (autoLoadRootNodes) {
await reloadTree();
}
if (autoSelectRootNode) {
const rootNodes = Repository.getRootNodes();
if (rootNodes.length) {
setSelection([rootNodes[0]]);
}
}
setIsReady(true);
})();
return () => {
Repository.off('beforeLoad', setTrue);
Repository.off('load', setFalse);
Repository.off('loadRootNodes', setFalse);
Repository.off('loadRootNodes', buildAndSetTreeNodeData);
Repository.off('add', buildAndSetOneTreeNodeData);
Repository.off('changeFilters', reloadTree);
Repository.off('changeSorters', reloadTree);
};
}, []);
useEffect(() => {
if (!Repository) {
return () => {};
}
if (!disableSelectorSelected && selectorId) {
let id = selectorSelected?.[selectorSelectedField] ?? null;
if (_.isEmpty(selectorSelected)) {
id = noSelectorMeansNoResults ? 'NO_MATCHES' : null;
}
Repository.filter(selectorId, id, false); // so it doesn't clear existing filters
}
}, [selectorId, selectorSelected]);
if (canUser && !canUser('view')) {
return <CenterBox>
<Unauthorized />
</CenterBox>;
}
if (setWithEditListeners) {
setWithEditListeners({ // Update withEdit's listeners on every render
onBeforeAdd,
onAfterAdd,
onAfterAddSave,
onBeforeEditSave,
onAfterEdit,
onBeforeDeleteSave,
onAfterDelete,
});
}
// update self with methods
if (self) {
self.reloadTree = reloadTree;
self.expandPath = expandPath;
self.scrollToNode = scrollToNode;
self.buildAndSetTreeNodeData = buildAndSetTreeNodeData;
self.forceUpdate = forceUpdate;
}
const
headerToolbarItemComponents = showHeaderToolbar ? useMemo(() => getHeaderToolbarItems(), [Repository?.hash, treeSearchValue, getTreeNodeData()]) : null,
footerToolbarItemComponents = useMemo(() => getFooterToolbarItems(), [Repository?.hash, additionalToolbarButtons, getTreeNodeData()]);
if (!isReady) {
return <CenterBox>
<Loading />
</CenterBox>;
}
const treeNodes = renderTreeNodes(getTreeNodeData());
// headers & footers
let treeFooterComponent = null;
if (!disableBottomToolbar) {
if (Repository && bottomToolbar === 'pagination' && !disablePagination && Repository.isPaginated) {
treeFooterComponent = <PaginationToolbar
Repository={Repository}
self={self}
toolbarItems={footerToolbarItemComponents}
/>;
} else if (footerToolbarItemComponents.length) {
treeFooterComponent = <Toolbar>
{!hideReloadBtn && <ReloadButton isTree={true} Repository={Repository} self={self} />}
{footerToolbarItemComponents}
</Toolbar>;
}
}
let className = clsx(
'Tree-VStack',
'flex-1',
'w-full',
fitToContainerWidth ? 'min-w-0' : 'min-w-[300px]',
);
if (isLoading) {
className += ' border-t-2 border-[#f00]';
} else {
className += ' border-t-1 border-grey-300';
}
if (props.className) {
className += ' ' + props.className;
}
return <VStackNative
{...testProps(self)}
className={className}
onLayout={onLayout}
>
{topToolbar}
{headerToolbarItemComponents?.length && <HStack>{headerToolbarItemComponents}</HStack>}
<VStack
ref={treeRef}
onClick={() => {
if (allowDeselectAll) {
deselectAll();
}
}}
className="Tree-deselector w-full flex-1 p-1 bg-white"
>
<ScrollView
{...testProps('ScrollView-Vertical')}
className="Tree-ScrollView flex-1 w-full"
contentContainerStyle={{
minHeight: '100%',
}}
>
<ScrollView
{...testProps('ScrollView-Horizontal')}
horizontal={!fitToContainerWidth}
className="w-full"
showsHorizontalScrollIndicator={!fitToContainerWidth}
contentContainerStyle={{
minWidth: '100%',
flexDirection: 'column', // Keep vertical stacking
}}
>
{!treeNodes?.length ?
<CenterBox>
{Repository.isLoading ? <Loading /> : <NoRecordsFound text={noneFoundText} onRefresh={reloadTree} />}
</CenterBox> :
treeNodes}
</ScrollView>
</ScrollView>
</VStack>
{treeFooterComponent}
</VStackNative>;
}
export const Tree = withComponent(
withAlert(
withEvents(
withData(
withPermissions(
withDropTarget(
// withMultiSelection(
withSelection(
withFilters(
withContextMenu(
TreeComponent
)
)
)
// )
)
)
)
)
)
);
export const SideTreeEditor = withComponent(
withAlert(
withEvents(
withData(
withPermissions(
withDropTarget(
// withMultiSelection(
withSelection(
withSideEditor(
withFilters(
withPresetButtons(
withContextMenu(
TreeComponent
)
)
),
true // isTree
)
)
// )
)
)
)
)
)
);
export const WindowedTreeEditor = withComponent(
withAlert(
withEvents(
withData(
withPermissions(
withDropTarget(
// withMultiSelection(
withSelection(
withWindowedEditor(
withFilters(
withPresetButtons(
withContextMenu(
TreeComponent
)
)
),
true // isTree
)
)
// )
)
)
)
)
)
);
export default Tree;