diff --git a/packages/toolpad-studio/src/toolpad/AppEditor/HierarchyExplorer/index.tsx b/packages/toolpad-studio/src/toolpad/AppEditor/HierarchyExplorer/index.tsx index f3f9ffec620..185400e6f36 100644 --- a/packages/toolpad-studio/src/toolpad/AppEditor/HierarchyExplorer/index.tsx +++ b/packages/toolpad-studio/src/toolpad/AppEditor/HierarchyExplorer/index.tsx @@ -1,22 +1,41 @@ import * as React from 'react'; +import clsx from 'clsx'; import { NodeId } from '@toolpad/studio-runtime'; -import { Box, Typography, styled } from '@mui/material'; -import { SimpleTreeView, TreeItem, TreeItemProps } from '@mui/x-tree-view'; +import { Box, Typography, styled, IconButton } from '@mui/material'; +import { SimpleTreeView, TreeItem, TreeItemProps, treeItemClasses } from '@mui/x-tree-view'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import useBoolean from '@toolpad/utils/hooks/useBoolean'; import * as appDom from '@toolpad/studio-runtime/appDom'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import invariant from 'invariant'; import { useAppState, useDomApi, useAppStateApi } from '../../AppState'; import { ComponentIcon } from '../PageEditor/ComponentCatalog/ComponentCatalogItem'; import { DomView } from '../../../utils/domView'; import { removePageLayoutNode } from '../pageLayout'; -import EditableTreeItem from '../../../components/EditableTreeItem'; +import EditableTreeItem, { EditableTreeItemProps } from '../../../components/EditableTreeItem'; import ExplorerHeader from '../ExplorerHeader'; +import NodeMenu from '../NodeMenu'; const CollapseIcon = styled(ExpandMoreIcon)({ fontSize: '0.9rem', opacity: 0.5 }); const ExpandIcon = styled(ChevronRightIcon)({ fontSize: '0.9rem', opacity: 0.5 }); -export interface CustomTreeItemProps extends TreeItemProps { +const classes = { + treeItemMenuButton: 'Toolpad__HierarchyListItem', + treeItemMenuOpen: 'Toolpad__HierarchyListItemMenuOpen', +}; + +const StyledTreeItem = styled(EditableTreeItem)({ + [`& .${classes.treeItemMenuButton}`]: { + visibility: 'hidden', + }, + [`& .${treeItemClasses.content}:hover .${classes.treeItemMenuButton}, & .${classes.treeItemMenuOpen}`]: + { + visibility: 'visible', + }, +}); + +interface CustomTreeItemProps extends TreeItemProps, EditableTreeItemProps { ref?: React.RefObject; node: appDom.ElementNode; } @@ -28,7 +47,11 @@ function CustomTreeItem(props: CustomTreeItemProps) { const { label, node, ...other } = props; - const { value: domNodeEditing, setFalse: stopDomNodeEditing } = useBoolean(false); + const { + value: domNodeEditing, + setTrue: startDomNodeEditing, + setFalse: stopDomNodeEditing, + } = useBoolean(false); const existingNames = React.useMemo(() => appDom.getExistingNamesForNode(dom, node), [dom, node]); @@ -50,8 +73,41 @@ function CustomTreeItem(props: CustomTreeItemProps) { const handleNameSave = React.useCallback( (newName: string) => { domApi.setNodeName(node.id, newName); + stopDomNodeEditing(); + }, + [domApi, node.id, stopDomNodeEditing], + ); + + const handleNodeDelete = React.useCallback( + (nodeId: NodeId) => { + domApi.update((draft) => { + const toRemove = appDom.getNode(draft, nodeId); + if (appDom.isElement(toRemove)) { + draft = removePageLayoutNode(draft, toRemove); + } + + return draft; + }); + }, + [domApi], + ); + + const handleNodeDuplicate = React.useCallback( + (nodeId: NodeId) => { + const currentNode = appDom.getNode(dom, nodeId); + + invariant( + node.parentId && node.parentProp, + 'Duplication should never be called on nodes that are not placed in the dom', + ); + + domApi.update((draft) => { + draft = appDom.duplicateNode(draft, currentNode); + + return draft; + }); }, - [domApi, node.id], + [dom, domApi, node.parentId, node.parentProp], ); const handleNodeHover = React.useCallback( @@ -66,7 +122,7 @@ function CustomTreeItem(props: CustomTreeItemProps) { }, [appStateApi]); return ( - ( @@ -83,6 +139,26 @@ function CustomTreeItem(props: CustomTreeItemProps) { sx={{ marginRight: 1, fontSize: 18, opacity: 0.5 }} /> {children} + {node.id ? ( + ( + + + + )} + nodeId={node.id} + onRenameNode={startDomNodeEditing} + onDuplicateNode={handleNodeDuplicate} + onDeleteNode={handleNodeDelete} + /> + ) : null} )} isEditing={domNodeEditing} diff --git a/packages/toolpad-studio/src/toolpad/AppEditor/NodeMenu.tsx b/packages/toolpad-studio/src/toolpad/AppEditor/NodeMenu.tsx index dad1c75f768..b62a00a1cba 100644 --- a/packages/toolpad-studio/src/toolpad/AppEditor/NodeMenu.tsx +++ b/packages/toolpad-studio/src/toolpad/AppEditor/NodeMenu.tsx @@ -24,9 +24,9 @@ export interface NodeMenuProps { export default function NodeMenu({ nodeId, renderButton, - renameLabelText, - deleteLabelText, - duplicateLabelText, + renameLabelText = 'Rename', + deleteLabelText = 'Delete', + duplicateLabelText = 'Duplicate', onRenameNode, onDeleteNode, onDuplicateNode,