@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
454 lines (452 loc) • 14.5 kB
JavaScript
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* Copyright (C) 2007-2022 Crafter Software Corporation. All Rights Reserved.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { TreeItem, treeItemClasses } from '@mui/x-tree-view/TreeItem';
import React, { useState } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import { Typography } from '@mui/material';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { makeStyles } from 'tss-react/mui';
import ItemDisplay from '../ItemDisplay';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import SearchBar from '../SearchBar/SearchBar';
import CloseIconRounded from '@mui/icons-material/CloseRounded';
import Button from '@mui/material/Button';
import ArrowRightRoundedIcon from '@mui/icons-material/ArrowRightRounded';
import ArrowDropDownRoundedIcon from '@mui/icons-material/ArrowDropDownRounded';
import { isBlank } from '../../utils/string';
import ErrorOutlineRounded from '@mui/icons-material/ErrorOutlineRounded';
import { lookupItemByPath } from '../../utils/content';
const translations = defineMessages({
filter: {
id: 'pathNavigatorTreeItemFilter.placeholder',
defaultMessage: 'Filter children...'
},
expand: {
id: 'words.expand',
defaultMessage: 'Expand'
},
collapse: {
id: 'words.collapse',
defaultMessage: 'Collapse'
}
});
const useStyles = makeStyles()((theme, _params, classes) => ({
root: {
[`&:focus > .${classes.content} .${classes.labelContainer}`]: {
background: 'none'
}
},
content: {
alignItems: 'flex-start',
paddingRight: 0,
'&:hover': {
background: 'none'
}
},
labelContainer: {
display: 'flex',
paddingLeft: 0,
flexWrap: 'wrap',
overflow: 'hidden',
'&:hover': {
background: 'none'
},
'& .MuiSvgIcon-root': {
fontSize: '1.1rem'
}
},
itemDisplaySection: {
width: '100%',
display: 'flex',
alignItems: 'center',
minHeight: '23.5px',
'&:hover': {
backgroundColor: theme.palette.mode === 'dark' ? theme.palette.action.hover : theme.palette.grey['A200']
}
},
filterSection: {
width: '100%',
display: 'flex',
alignItems: 'center'
},
empty: {
color: theme.palette.text.secondary,
display: 'flex',
alignItems: 'center',
minHeight: '23.5px',
marginLeft: '10px',
'& svg': {
marginRight: '5px',
fontSize: '1.1rem'
}
},
more: {
color: theme.palette.text.primary,
display: 'flex',
alignItems: 'center',
minHeight: '23.5px',
marginLeft: '10px'
},
iconContainer: {
width: '26px',
marginRight: 0,
'& svg': {
fontSize: '23.5px !important',
color: theme.palette.text.secondary
}
},
focused: {
background: 'none !important'
},
loading: {
display: 'flex',
alignItems: 'center',
minHeight: '23.5px',
marginLeft: '10px',
'& span': {
marginLeft: '10px'
}
},
searchRoot: {
margin: '5px 10px 5px 0',
height: '25px',
width: '100%'
},
searchInput: {
fontSize: '12px',
padding: '5px !important'
},
searchCloseButton: {
marginRight: '10px'
},
searchCloseIcon: {
fontSize: '12px !important'
},
iconButton: {
padding: '2px 3px'
},
active: {
backgroundColor: theme.palette.action.selected
}
}));
export function PathNavigatorTreeItem(props) {
const {
path,
itemsByPath,
keywordByPath,
totalByPath,
errorByPath,
childrenByParentPath,
active = {},
showNavigableAsLinks = true,
showPublishingTarget = true,
showWorkflowState = true,
showItemMenu = true,
onLabelClick,
onIconClick,
onOpenItemMenu,
onFilterChange,
onMoreClick
} = props;
const { classes, cx } = useStyles();
const [over, setOver] = useState(false);
const [showFilter, setShowFilter] = useState(Boolean(keywordByPath[path]));
const [keyword, setKeyword] = useState(keywordByPath[path] ?? '');
const { formatMessage } = useIntl();
const item = lookupItemByPath(path, itemsByPath);
const children = lookupItemByPath(path, childrenByParentPath) ?? [];
const onMouseOver = (e) => {
e.stopPropagation();
setOver(true);
};
const onMouseLeave = (e) => {
e.stopPropagation();
setOver(false);
};
const onFilterButtonClick = () => {
setShowFilter(!showFilter);
};
const onClearKeywords = () => {
if (keyword) {
setKeyword('');
onFilterChange('', path);
}
};
const onContextMenu = (e) => {
if (onOpenItemMenu) {
e.preventDefault();
onOpenItemMenu(e.currentTarget.querySelector('[data-item-menu]'), path);
}
};
// Children for TreeItem set here this way instead of as JSX children below since, because there
// are multiple blocks, that would cause all nodes to have an "open" icon even if they have no children.
const propsForTreeItem = { children: [] };
if (children.length) {
propsForTreeItem.children = children.map((path) =>
React.createElement(PathNavigatorTreeItem, {
key: path,
path: path,
itemsByPath: itemsByPath,
keywordByPath: keywordByPath,
totalByPath: totalByPath,
childrenByParentPath: childrenByParentPath,
errorByPath: errorByPath,
active: active,
onLabelClick: onLabelClick,
onIconClick: onIconClick,
onOpenItemMenu: onOpenItemMenu,
onFilterChange: onFilterChange,
onMoreClick: onMoreClick,
showNavigableAsLinks: showNavigableAsLinks,
showPublishingTarget: showPublishingTarget,
showWorkflowState: showWorkflowState,
showItemMenu: showItemMenu
})
);
children.length < totalByPath[path] &&
propsForTreeItem.children.push(
React.createElement(
'section',
{ key: 'more', className: classes.more },
React.createElement(
Button,
{
color: 'primary',
size: 'small',
onClick: () => {
onMoreClick(path);
}
},
React.createElement(FormattedMessage, {
id: 'pathNavigatorTree.moreLinkLabel',
defaultMessage: '{count, plural, one {...{count} more item} other {...{count} more items}}',
values: { count: totalByPath[path] - children.length }
})
)
)
);
} else if (totalByPath[path] > 0 && !childrenByParentPath.length) {
propsForTreeItem.children.push(
errorByPath[path]
? React.createElement(
'div',
{ key: 'loading', className: classes.loading },
React.createElement(
Typography,
{ variant: 'caption', color: 'error.main' },
React.createElement(FormattedMessage, {
defaultMessage: 'Error: {message}',
values: { message: errorByPath[path]?.response?.message ?? errorByPath[path].message }
})
)
)
: React.createElement(
'div',
{ key: 'loading', className: classes.loading },
React.createElement(CircularProgress, { size: 14 }),
React.createElement(
Typography,
{ variant: 'caption', color: 'textSecondary' },
React.createElement(FormattedMessage, { id: 'words.loading', defaultMessage: 'Loading' })
)
)
);
} else if (!isBlank(keywordByPath[path]) && totalByPath[path] === 0) {
propsForTreeItem.children.push(
React.createElement(
'section',
{ key: 'noResults', className: classes.empty },
React.createElement(ErrorOutlineRounded, null),
React.createElement(
Typography,
{ variant: 'caption' },
React.createElement(FormattedMessage, {
id: 'filter.noResults',
defaultMessage: 'No results match your query'
})
)
)
);
}
return (
// region <TreeItem ... />
React.createElement(TreeItem, {
key: path,
itemId: path,
slots: {
expandIcon: ArrowRightRoundedIcon,
collapseIcon: ArrowDropDownRoundedIcon
},
slotProps: {
expandIcon: {
role: 'button',
'aria-label': formatMessage(translations.expand),
'aria-hidden': 'false',
onClick: () => onIconClick(path)
},
collapseIcon: {
role: 'button',
'aria-label': formatMessage(translations.collapse),
'aria-hidden': 'false',
onClick: () => onIconClick(path)
}
},
label: React.createElement(
React.Fragment,
null,
React.createElement(
'section',
{
role: 'button',
onClick: (event) => onLabelClick(event, path),
className: classes.itemDisplaySection,
onMouseOver: onMouseOver,
onMouseLeave: onMouseLeave,
onContextMenu: onContextMenu
},
React.createElement(ItemDisplay, {
styles: {
root: {
flex: 1,
minWidth: 0,
minHeight: '23.5px'
}
},
item: item,
labelTypographyProps: { variant: 'body2' },
showNavigableAsLinks: showNavigableAsLinks,
showPublishingTarget: showPublishingTarget,
showWorkflowState: showWorkflowState
}),
over &&
showItemMenu &&
onOpenItemMenu &&
React.createElement(
Tooltip,
{ title: React.createElement(FormattedMessage, { id: 'words.options', defaultMessage: 'Options' }) },
React.createElement(
IconButton,
{
size: 'small',
className: classes.iconButton,
'data-item-menu': true,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
onOpenItemMenu(e.currentTarget, path);
}
},
React.createElement(MoreVertRoundedIcon, null)
)
),
(showFilter || (over && Boolean(item.childrenCount))) &&
React.createElement(
Tooltip,
{ title: React.createElement(FormattedMessage, { id: 'words.filter', defaultMessage: 'Filter' }) },
React.createElement(
IconButton,
{
size: 'small',
className: classes.iconButton,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
onClearKeywords();
onFilterButtonClick();
}
},
React.createElement(SearchRoundedIcon, { color: showFilter ? 'primary' : 'action' })
)
)
),
showFilter &&
React.createElement(
'section',
{ className: classes.filterSection },
React.createElement(SearchBar, {
autoFocus: true,
onClick: (e) => e.stopPropagation(),
onKeyDown: (e) => e.stopPropagation(),
onChange: (keyword) => {
setKeyword(keyword);
onFilterChange(keyword, path);
},
keyword: keyword,
placeholder: formatMessage(translations.filter),
onActionButtonClick: (e, input) => {
e.stopPropagation();
onClearKeywords();
input.focus();
},
showActionButton: keyword && true,
classes: {
root: cx(classes.searchRoot, props.classes?.searchRoot),
inputInput: cx(classes.searchInput, props.classes?.searchInput),
actionIcon: cx(classes.searchCloseIcon, props.classes?.searchCleanButton)
}
}),
React.createElement(
IconButton,
{
size: 'small',
onClick: (e) => {
e.stopPropagation();
onClearKeywords();
setShowFilter(false);
},
className: cx(classes.searchCloseButton, props.classes?.searchCloseButton)
},
React.createElement(CloseIconRounded, null)
)
)
),
classes: {
root: classes.root,
content: classes.content,
label: cx(classes.labelContainer, active[path] && classes.active),
iconContainer: classes.iconContainer,
focused: classes.focused
},
sx: {
[`& .${treeItemClasses.content}`]: {
pt: 0,
pb: 0
}
},
...propsForTreeItem
})
// endregion
);
}
export default PathNavigatorTreeItem;