@onehat/ui
Version:
Base UI for OneHat apps
1,549 lines (1,398 loc) • 44.4 kB
JavaScript
import { useState, useEffect, useRef, useMemo, } from 'react';
import {
HStack,
Pressable,
ScrollView,
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;
// NOTE: If using TreeComponent with getCustomDragProxy, ensure that <GlobalDragProxy /> exists in App.js
function TreeComponent(props) {
const {
areRootsVisible = true,
autoLoadRootNodes = true,
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 {};
},
noneFoundText,
disableLoadingIndicator = false,
disableSelectorSelected = false,
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
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
getCustomDragProxy, // optional fn to render custom drag preview: (item, selection) => ReactElement
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,
} = 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(''),
// 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)) {
deselectAll();
}
}
forceUpdate();
},
onCollapseAll = () => {
const newTreeNodeData = [...getTreeNodeData()];
collapseNodes(newTreeNodeData);
setTreeNodeData(newTreeNodeData);
},
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;
}
const selectedNode = selectedNodes[0];
const commonAncestorId = await Repository.moveTreeNode(selectedNode, droppedOn.id);
const commonAncestorDatum = getDatumById(commonAncestorId);
reloadNode(commonAncestorDatum.item);
},
// 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.areRootNodesLoaded) {
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);
}
}}
className={clsx(
'Pressable',
'Node',
'flex-row',
)}
style={{
paddingLeft: (areRootsVisible ? depth : depth -1) * DEPTH_INDENT_PX,
}}
>
{({
hovered,
focused,
pressed,
}) => {
const nodeDragProps = {};
let WhichNode = TreeNode;
if (CURRENT_MODE === UI_MODE_WEB) { // DND is currently web-only TODO: implement for RN
// Create a method that gets an always-current copy of the selection ids
dragSelectionRef.current = selection;
const getSelection = () => dragSelectionRef.current;
const userHasPermissionToDrag = (!canUser || canUser(EDIT));
if (userHasPermissionToDrag) {
// NOTE: The Tree can either drag nodes internally or externally, but not both at the same time!
// assign event handlers
if (canNodesMoveInternally) {
// internal drag/drop
const nodeDragSourceType = 'internal';
WhichNode = DragSourceDropTargetTreeNode;
nodeDragProps.isDragSource = !item.isRoot; // Root nodes cannot be dragged
nodeDragProps.dragSourceType = nodeDragSourceType;
nodeDragProps.dragSourceItem = {
id: item.id,
item,
getSelection,
isInSelection,
type: nodeDragSourceType,
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]);
}
},
};
// Prevent root nodes from being dragged, and use custom logic if provided
nodeDragProps.canDrag = (monitor) => {
const currentSelection = getSelection();
if (isInSelection(item)) {
// make sure root node is not selected (can't drag root nodes)
const hasRootNode = currentSelection.some(node => node.isRoot);
if (hasRootNode) {
return false;
}
}
// Use custom drag validation if provided
if (canNodeMoveInternally) {
// In multi-selection, all nodes must be draggable
return currentSelection.every(node => canNodeMoveInternally(node));
}
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
const dropTargetAccept = 'internal';
nodeDragProps.isDropTarget = true;
nodeDragProps.dropTargetAccept = dropTargetAccept;
// Define validation logic once for reuse
const validateDrop = (draggedItem) => {
if (!draggedItem) {
return false;
}
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];
// 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;
}
}
if (canNodeAcceptDrop && typeof canNodeAcceptDrop === 'function') {
// custom business logic
return canNodeAcceptDrop(item, draggedItem);
}
return true;
};
// Use the validation function for React DnD
nodeDragProps.canDrop = (draggedItem, monitor) => validateDrop(draggedItem);
// Pass the same validation function for visual feedback
nodeDragProps.validateDrop = validateDrop;
nodeDragProps.onDrop = (droppedItem) => {
if (belongsToThisTree(droppedItem)) {
onInternalNodeDrop(item, droppedItem);
}
};
} else {
// external drag/drop
if (areNodesDragSource) {
WhichNode = DragSourceTreeNode;
nodeDragProps.isDragSource = !item.isRoot; // Root nodes cannot be dragged
nodeDragProps.dragSourceType = nodeDragSourceType;
if (getNodeDragSourceItem) {
nodeDragProps.dragSourceItem = getNodeDragSourceItem(item, getSelection, isInSelection, nodeDragSourceType);
} else {
nodeDragProps.dragSourceItem = {
id: item.id,
item,
getSelection,
isInSelection,
type: nodeDragSourceType,
};
}
nodeDragProps.dragSourceItem.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]);
}
};
if (canNodeMoveExternally) {
nodeDragProps.canDrag = canNodeMoveExternally;
}
// 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
}
if (areNodesDropTarget) {
WhichNode = DropTargetTreeNode;
nodeDragProps.isDropTarget = true;
nodeDragProps.dropTargetAccept = dropTargetAccept;
nodeDragProps.canDrop = (droppedItem, monitor) => {
// Check if the drop operation would be valid based on business rules
if (canNodeAcceptDrop && typeof canNodeAcceptDrop === 'function') {
return canNodeAcceptDrop(item, droppedItem);
}
// Default: allow external drops
return true;
};
// Define validation logic once for reuse
const validateDrop = (draggedItem) => {
if (!draggedItem) {
return false;
}
if (canNodeAcceptDrop && typeof canNodeAcceptDrop === 'function') {
// custom business logic
return canNodeAcceptDrop(item, draggedItem);
}
return true;
};
// Use the validation function for React DnD
nodeDragProps.canDrop = (draggedItem, monitor) => validateDrop(draggedItem);
// Pass the same validation function for visual feedback
nodeDragProps.validateDrop = validateDrop;
nodeDragProps.onDrop = (droppedItem) => {
// NOTE: item is sometimes getting destroyed, but it still has the id, so you can still use it
onNodeDrop(item, droppedItem);
};
}
if (areNodesDragSource && areNodesDropTarget) {
WhichNode = DragSourceDropTargetTreeNode;
}
}
}
}
return <WhichNode
datum={datum}
nodeProps={nodeProps}
onToggle={onToggle}
isNodeSelectable={isNodeSelectable}
isNodeHoverable={isNodeHoverable}
isSelected={isSelected}
isHovered={hovered}
showHovers={showHovers}
showSelectHandle={showSelectHandle}
isHighlighted={highlitedDatum === datum}
{...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();
}
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 = useMemo(() => getHeaderToolbarItems(), [Repository?.hash, treeSearchValue, getTreeNodeData()]),
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>
<ReloadButton isTree={true} Repository={Repository} self={self} />
{footerToolbarItemComponents}
</Toolbar>;
}
}
let className = clsx(
'Tree-VStack',
'flex-1',
'w-full',
'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={() => {
deselectAll();
}}
className="Tree-deselector w-full flex-1 p-1 bg-white"
>
<ScrollView
{...testProps('ScrollView')}
className="Tree-ScrollView flex-1 w-full"
contentContainerStyle={{
height: '100%',
}}
>
{!treeNodes?.length ?
<CenterBox>
{Repository.isLoading ? <Loading /> : <NoRecordsFound text={noneFoundText} onRefresh={reloadTree} />}
</CenterBox> :
treeNodes}
</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;