@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
816 lines (814 loc) • 27.2 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 React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { makeStyles } from 'tss-react/mui';
import TreeView from '@mui/lab/TreeView';
import IconButton from '@mui/material/IconButton';
import ExpandMoreIcon from '@mui/icons-material/ExpandMoreRounded';
import ChevronRightIcon from '@mui/icons-material/ChevronRightRounded';
import MoreVertIcon from '@mui/icons-material/MoreVertRounded';
import TreeItem from '@mui/lab/TreeItem';
import MuiBreadcrumbs from '@mui/material/Breadcrumbs';
import Page from '../../icons/Page';
import ContentTypeFieldIcon from '../../icons/ContentTypeField';
import Component from '../../icons/Component';
import NodeSelector from '../../icons/NodeSelector';
import RepeatGroupItem from '../../icons/RepeatGroupItem';
import Root from '@mui/icons-material/HomeRounded';
import NavigateNextIcon from '@mui/icons-material/NavigateNextRounded';
import RepeatGroup from '../../icons/RepeatGroup';
import { hierarchicalToLookupTable } from '../../utils/object';
import {
clearContentTreeFieldSelected,
contentTreeFieldSelected,
deleteItemOperationComplete,
sortItemOperationComplete
} from '../../state/actions/preview';
import { getHostToGuestBus, getHostToHostBus } from '../../utils/subjects';
import Suspencified from '../Suspencified/Suspencified';
import palette from '../../styles/palette';
import { useDispatch } from 'react-redux';
import Typography from '@mui/material/Typography';
import Link from '@mui/material/Link';
import ItemActionsMenu from '../ItemActionsMenu';
import SearchBar from '../SearchBar/SearchBar';
import Divider from '@mui/material/Divider';
import { getOffsetLeft, getOffsetTop } from '@mui/material/Popover';
import { showItemMegaMenu } from '../../state/actions/dialogs';
import { useSelection } from '../../hooks/useSelection';
import { useActiveSiteId } from '../../hooks/useActiveSiteId';
import { usePreviewGuest } from '../../hooks/usePreviewGuest';
import { useLogicResource } from '../../hooks/useLogicResource';
import { useUnmount } from '../../hooks/useUnmount';
import { useSpreadState } from '../../hooks/useSpreadState';
const rootPrefix = '{root}_';
const translations = defineMessages({
title: {
id: 'previewPageExplorerPanel.title',
defaultMessage: 'Page Explorer'
},
loading: {
id: 'previewPageExplorerPanel.loading',
defaultMessage: 'Loading'
},
onThisPage: {
id: 'previewPageExplorerPanel.rootItemLabel',
defaultMessage: 'Current Content Items'
}
});
const useStyles = makeStyles()((theme) => ({
root: {
'& > li > ul': {
marginLeft: '0'
}
},
searchWrapper: {
padding: '10px'
},
divider: {
marginTop: '10px'
},
rootIcon: {
fontSize: '1.2em',
color: theme.palette.mode === 'dark' ? palette.white : palette.gray.medium7
},
breadcrumbs: {
display: 'flex',
alignItems: 'center'
},
breadcrumbsList: {
display: 'flex',
alignItems: 'center',
padding: '9px 10px 2px 8px'
},
breadcrumbsSeparator: {
margin: '0 2px'
},
breadcrumbsButton: {
display: 'flex'
},
breadcrumbsTypography: {
color: theme.palette.mode === 'dark' ? palette.white : palette.gray.medium4
},
currentContentItems: {
fontWeight: 600,
color: theme.palette.mode === 'dark' ? palette.white : palette.gray.medium7,
padding: '0 12px 2px 12px'
},
chevron: {
color: theme.palette.mode === 'dark' ? palette.white : palette.gray.medium3,
fontSize: '1.4rem'
}
}));
const treeItemStyles = makeStyles()((theme, _params, classes) => ({
icon: {
color: palette.teal.main
},
displayNone: {
display: 'none'
},
treeItemIconContainer: {},
treeItemRoot: {
'&:focus > .MuiTreeItem-content': {
'& .MuiTreeItem-label': {
backgroundColor: 'inherit'
},
'& .MuiTreeItem-label:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)'
}
}
},
treeItemContent: {
paddingLeft: '8px',
'&.padded': {
paddingLeft: '15px'
},
'&.root': {
paddingLeft: 0,
[`& .${classes.treeItemLabelRoot}`]: {
paddingLeft: '6px'
}
}
},
treeItemGroup: {},
treeItemExpanded: {},
treeItemSelected: {},
treeItemLabelRoot: {
paddingLeft: 0
},
treeItemLabel: {
display: 'flex',
alignItems: 'center',
height: '36px',
'& p': {
marginTop: 0,
marginLeft: '5px',
marginRight: '5px',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
marginBottom: 0,
wordBreak: 'break-all'
}
},
options: {
marginLeft: 'auto',
padding: '6px'
},
chevron: {
color: theme.palette.mode === 'dark' ? palette.white : palette.gray.medium3,
fontSize: '1.4rem'
},
nameLabel: {
color: theme.palette.mode === 'dark' ? palette.white : palette.gray.medium4
}
}));
function getNodeSelectorChildren(model, parentModelId, path, itemContentTypeName, fieldId, index) {
return {
id: `${parentModelId}_${fieldId}_${index}`,
name: `${itemContentTypeName}: ${model.craftercms.label}`,
type: 'component',
modelId: `${model.craftercms.id}`,
parentId: parentModelId,
embeddedParentPath: path || null,
fieldId,
index
};
}
function getRepeatGroupChildren(item, contentTypeField, contentTypes, parentTreeItemId, model, models, index) {
let children = [];
Object.keys(item).forEach((fieldName) => {
let subChildren = [];
let fieldId = fieldName;
if (contentTypeField) return;
const { type, name } = contentTypeField.fields[fieldName];
if (type === 'node-selector') {
fieldId = `${contentTypeField.id}.${fieldName}`;
item[fieldName].forEach((id, i) => {
let itemContentTypeName = contentTypes[models[id].craftercms.contentTypeId].name;
subChildren.push(
getNodeSelectorChildren(
models[id],
model.craftercms.id,
models[id].craftercms.path ? null : model.craftercms.path,
itemContentTypeName,
fieldId,
`${index}.${i}`
)
);
});
}
children.push({
id: `${parentTreeItemId}_${fieldName}`,
name,
type,
children: subChildren,
modelId: model.craftercms.id,
fieldId,
index
});
});
return children;
}
function getChildren(model, contentType, models, contentTypes) {
let children = [];
Object.keys(model).forEach((fieldName) => {
if (fieldName === 'craftercms') return;
const contentTypeField = getContentTypeField(contentType, fieldName, contentTypes);
if (!contentTypeField) return;
const { type, name } = contentTypeField;
let subChildren = [];
if (type === 'node-selector') {
model[fieldName].forEach((id, i) => {
let itemContentTypeName = contentTypes[models[id].craftercms.contentTypeId].name;
subChildren.push(
getNodeSelectorChildren(
models[id],
model.craftercms.id,
models[id].craftercms.path ? null : model.craftercms.path,
itemContentTypeName,
fieldName,
i
)
);
});
} else if (type === 'repeat') {
model[fieldName].forEach((item, index) => {
let id = `${model.craftercms.id}_${fieldName}_${index}`;
subChildren.push({
id,
name: `Item ${index + 1}`,
type: 'item',
children: getRepeatGroupChildren(
item,
getContentTypeField(contentType, fieldName, contentTypes),
contentTypes,
id,
model,
models,
index
),
modelId: model.craftercms.id,
fieldId: `${fieldName}`,
index
});
});
}
children.push({
id: `${model.craftercms.id}_${fieldName}`,
name,
type,
children: subChildren,
modelId: model.craftercms.id,
path: model.craftercms.path,
fieldId: fieldName
});
});
return children;
}
function getContentTypeField(contentType, fieldName, contentTypes) {
var _a, _b;
return (_b =
(_a = contentType.fields[fieldName]) !== null && _a !== void 0
? _a
: contentTypes['/component/level-descriptor'].fields[fieldName]) !== null && _b !== void 0
? _b
: null;
}
function TreeItemCustom(props) {
var _a;
const { nodeLookup, node, handleScroll, handleClick, handleOptions, isRootChild, keyword } = props;
const { classes, cx } = treeItemStyles();
const [over, setOver] = useState(false);
let timeout = React.useRef();
const isMounted = useRef(null);
let Icon;
const nodeName = node.name.split(':');
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
if (!node) {
return null;
} else {
if (node.type === 'page') {
Icon = Page;
} else if (node.type === 'node-selector') {
Icon = NodeSelector;
} else if (node.type === 'component') {
Icon = Component;
} else if (node.type === 'repeat') {
Icon = RepeatGroup;
} else if (node.type === 'item') {
Icon = RepeatGroupItem;
} else if (node.type === 'root') {
Icon = Root;
} else {
Icon = ContentTypeFieldIcon;
}
}
function setOverState(e, isOver) {
e.stopPropagation();
clearTimeout(timeout.current);
if (!isOver) {
timeout.current = setTimeout(() => {
isMounted.current && setOver(false);
}, 50);
} else {
isMounted.current && setOver(isOver);
}
}
function isRoot(id) {
return id.includes('root') || node.id.includes(rootPrefix);
}
function isPageOrComponent(type) {
return node.type === 'component' || node.type === 'page';
}
const children = keyword
? (_a = node.children) === null || _a === void 0
? void 0
: _a.filter((childNodeId) => nodeLookup[String(childNodeId)].name.toLowerCase().includes(keyword.toLowerCase()))
: node.children;
return React.createElement(
TreeItem,
{
key: node.id,
nodeId: node.id,
onMouseOver: (e) => setOverState(e, true),
onMouseOut: (e) => setOverState(e, false),
icon:
isPageOrComponent(node.type) &&
React.createElement(ChevronRightIcon, { onClick: () => handleClick(node), className: classes.chevron }),
label: React.createElement(
'div',
{ className: classes.treeItemLabel, onClick: () => handleScroll(node) },
React.createElement(Icon, { className: classes.icon }),
React.createElement(
'p',
{ title: node.name },
nodeName.length === 1
? nodeName[0]
: React.createElement(
React.Fragment,
null,
nodeName[1],
' ',
React.createElement('span', { className: classes.nameLabel }, `(${nodeName[0].trim()})`)
)
),
over &&
node.path &&
React.createElement(
IconButton,
{
className: classes.options,
onMouseOver: (e) => setOverState(e, true),
onClick: (e) => handleOptions(e, node),
size: 'large'
},
React.createElement(MoreVertIcon, null)
)
),
classes: {
root: classes.treeItemRoot,
label: classes.treeItemLabelRoot,
content: cx(
classes.treeItemContent,
isPageOrComponent(node.type) && !isRootChild && 'padded',
isRoot(node.id) && 'root'
),
expanded: classes.treeItemExpanded,
selected: classes.treeItemSelected,
group: classes.treeItemGroup,
iconContainer: isRoot(node.id) ? classes.displayNone : classes.treeItemIconContainer
}
},
children === null || children === void 0
? void 0
: children.map((childNodeId, i) =>
React.createElement(
TreeItemCustom,
Object.assign({}, props, {
key: String(childNodeId) + i,
node: nodeLookup[String(childNodeId)],
isRootChild: node.type === 'root'
})
)
)
);
}
export function PreviewPageExplorerPanel() {
const dispatch = useDispatch();
const guest = usePreviewGuest();
const currentModelId = guest === null || guest === void 0 ? void 0 : guest.modelId;
const { classes, cx } = useStyles();
const { formatMessage } = useIntl();
const contentTypesBranch = useSelection((state) => state.contentTypes);
const hostToGuest$ = getHostToGuestBus();
const hostToHost$ = getHostToHostBus();
const site = useActiveSiteId();
const [keyword, setKeyword] = useState('');
const [optionsMenu, setOptionsMenu] = useState({
modelId: null,
anchorEl: null,
path: null
});
const [state, setState] = React.useState({
selected: `root`,
expanded: ['root'],
breadcrumbs: ['root']
});
const [nodeLookup, setNodeLookup] = useSpreadState({});
const ContentTypesById =
contentTypesBranch === null || contentTypesBranch === void 0 ? void 0 : contentTypesBranch.byId;
const models = guest === null || guest === void 0 ? void 0 : guest.models;
const processedModels = useRef({});
const updateNode = useCallback(
(modelId, fieldId, nodeId) => {
const model = models[modelId];
const children = [];
// TODO: IF deleted op, optional: remove deleted id from nodeLookup
if (nodeLookup[nodeId].type === 'node-selector') {
model[fieldId].forEach((id, i) => {
let itemContentTypeName = ContentTypesById[models[id].craftercms.contentTypeId].name;
children.push(
getNodeSelectorChildren(
models[id],
model.craftercms.id,
models[id].craftercms.path ? null : model.craftercms.path,
itemContentTypeName,
fieldId,
i
)
);
});
const updatedNode = Object.assign(Object.assign({}, nodeLookup[nodeId]), {
children: children.map((node) => node.id)
});
setNodeLookup(Object.assign({ [nodeId]: updatedNode }, hierarchicalToLookupTable(children)));
} else if (nodeLookup[nodeId].type === 'page') {
const contentType = ContentTypesById[model.craftercms.contentTypeId];
const rootNode = Object.assign(Object.assign({}, nodeLookup[nodeId]), {
children: getChildren(model, contentType, models, ContentTypesById)
});
setNodeLookup(Object.assign({}, hierarchicalToLookupTable(rootNode)));
}
},
[ContentTypesById, models, nodeLookup, setNodeLookup]
);
const updateRoot = useCallback(
(modelId, fieldId, index, update) => {
processedModels.current = {};
Object.values(models).forEach((model) => {
processedModels.current[model.craftercms.id] = true;
});
update();
},
[models]
);
// effect to refresh the contentTree if the models are updated
useEffect(() => {
const sub = hostToHost$.subscribe((action) => {
switch (action.type) {
case deleteItemOperationComplete.type:
case sortItemOperationComplete.type: {
const { modelId, fieldId, index } = action.payload;
if (state.expanded.includes(`${modelId}_${fieldId}`)) {
updateNode(modelId, fieldId, `${modelId}_${fieldId}`);
} else if (state.selected.includes(modelId)) {
updateNode(modelId, fieldId, `${rootPrefix}${modelId}`);
} else if (state.selected === 'root' && action.type === deleteItemOperationComplete.type) {
updateRoot(modelId, fieldId, index, () => setState(Object.assign({}, state)));
}
}
}
});
return () => {
sub.unsubscribe();
};
}, [dispatch, hostToHost$, state, updateNode, updateRoot]);
// effect to refresh the contentTree if the site changes;
useEffect(() => {
if (site && currentModelId) {
processedModels.current = {};
setState({
selected: `root`,
expanded: ['root'],
breadcrumbs: ['root']
});
}
}, [site, currentModelId]);
useEffect(() => {
if (models && ContentTypesById) {
let _nodeLookup = {};
let shouldSetState = false;
Object.values(models).forEach((model) => {
let contentType = ContentTypesById[model.craftercms.contentTypeId];
if (!processedModels.current[model.craftercms.id] && contentType) {
processedModels.current[model.craftercms.id] = true;
let node = {
id: model.craftercms.id,
name: `${contentType.name}: ${model.craftercms.label}`,
children: [],
type: contentType.type,
path: model.craftercms.path,
modelId: model.craftercms.id
};
shouldSetState = true;
_nodeLookup[node.id] = node;
}
});
if (shouldSetState) {
setNodeLookup(_nodeLookup);
}
}
}, [ContentTypesById, formatMessage, models, setNodeLookup]);
useEffect(() => {
const handler = (e) => {
if (e.keyCode === 27) {
hostToGuest$.next({ type: clearContentTreeFieldSelected.type });
}
};
document.addEventListener('keydown', handler, false);
return () => document.removeEventListener('keydown', handler, false);
}, [dispatch, hostToGuest$]);
const onBack = () => {
hostToGuest$.next({ type: 'CLEAR_CONTENT_TREE_FIELD_SELECTED' });
};
const handleClick = (node) => {
if ((node.type === 'component' || node.type === 'page') && !node.id.includes(rootPrefix)) {
let model = models[node.modelId];
let contentType = ContentTypesById[model.craftercms.contentTypeId];
const rootNode = Object.assign(Object.assign({}, node), {
id: `${rootPrefix}${node.id}`,
children: getChildren(model, contentType, models, ContentTypesById)
});
setNodeLookup(Object.assign({}, hierarchicalToLookupTable(rootNode)));
setKeyword('');
setState({
selected: node.id,
expanded: [`${rootPrefix}${node.id}`],
breadcrumbs: [...state.breadcrumbs, node.id]
});
}
};
const handleScroll = (node) => {
var _a, _b;
hostToGuest$.next({
type: contentTreeFieldSelected.type,
payload: {
name: node.name,
modelId: node.parentId || node.modelId,
fieldId: (_a = node.fieldId) !== null && _a !== void 0 ? _a : null,
index: (_b = node.index) !== null && _b !== void 0 ? _b : null
}
});
return;
};
const handleBreadCrumbClick = (event, node) => {
event.stopPropagation();
if (!state.breadcrumbs.length) return null;
let breadcrumbsArray = [...state.breadcrumbs];
breadcrumbsArray = breadcrumbsArray.slice(0, breadcrumbsArray.indexOf(node.id) + 1);
setState(
Object.assign(Object.assign({}, state), {
selected: node.id,
expanded: node.id === 'root' ? [node.id] : [`${rootPrefix}${node.id}`],
breadcrumbs: breadcrumbsArray
})
);
};
const handleChange = (event, nodes) => {
if (event.target.classList.contains('toggle') || event.target.parentElement.classList.contains('toggle')) {
setState(Object.assign(Object.assign({}, state), { expanded: [...nodes] }));
}
};
const handleOptions = (event, node) => {
event.stopPropagation();
const path = node.path;
const element = event.currentTarget;
const anchorRect = element.getBoundingClientRect();
const top = anchorRect.top + getOffsetTop(anchorRect, 'top');
const left = anchorRect.left + getOffsetLeft(anchorRect, 'left');
if (path) {
dispatch(
showItemMegaMenu({
path: path,
anchorReference: 'anchorPosition',
anchorPosition: { top, left }
})
);
}
};
const handleClose = () => setOptionsMenu(Object.assign(Object.assign({}, optionsMenu), { anchorEl: null }));
const handleSearchKeyword = (keyword) => {
setKeyword(keyword);
};
const resource = useLogicResource(
{ models, byId: ContentTypesById },
{
shouldResolve: (source) => Boolean(source.models && source.byId),
shouldReject: () => false,
shouldRenew: () => !Object.keys(processedModels.current).length && resource.complete,
resultSelector: () => true,
errorSelector: null
}
);
useUnmount(onBack);
return React.createElement(
React.Fragment,
null,
React.createElement(
TreeView,
{
className: classes.root,
defaultCollapseIcon: React.createElement(ExpandMoreIcon, { className: cx('toggle', classes.chevron) }),
defaultExpandIcon: React.createElement(ChevronRightIcon, { className: cx('toggle', classes.chevron) }),
disableSelection: true,
expanded: state.expanded,
onNodeToggle: handleChange
},
React.createElement(
'div',
{ className: classes.searchWrapper },
React.createElement(SearchBar, {
showActionButton: Boolean(keyword),
onChange: handleSearchKeyword,
keyword: keyword
}),
React.createElement(Divider, { className: classes.divider })
),
React.createElement(
Suspencified,
{ loadingStateProps: { title: formatMessage(translations.loading) } },
React.createElement(PageExplorerUI, {
handleBreadCrumbClick: handleBreadCrumbClick,
handleClick: handleClick,
handleClose: handleClose,
handleScroll: handleScroll,
optionsMenu: optionsMenu,
rootPrefix: rootPrefix,
handleOptions: handleOptions,
resource: resource,
keyword: keyword,
nodeLookup: nodeLookup,
selected: state.selected,
breadcrumbs: state.breadcrumbs,
rootChildren: Object.keys(processedModels.current)
})
)
)
);
}
function PageExplorerUI(props) {
var _a;
const {
resource,
handleScroll,
handleClick,
handleOptions,
handleClose,
handleBreadCrumbClick,
optionsMenu,
rootPrefix,
nodeLookup,
selected,
keyword,
breadcrumbs,
rootChildren
} = props;
const { classes } = useStyles();
const { formatMessage } = useIntl();
resource.read();
let node = null;
if (selected === 'root') {
node = {
id: 'root',
name: formatMessage(translations.onThisPage),
children: rootChildren,
type: 'root',
modelId: 'root'
};
} else {
node = nodeLookup[`${rootPrefix}${selected}`];
}
return React.createElement(
React.Fragment,
null,
Boolean(breadcrumbs.length > 1) &&
React.createElement(
MuiBreadcrumbs,
{
maxItems: 2,
'aria-label': 'Breadcrumbs',
separator: React.createElement(NavigateNextIcon, { fontSize: 'small' }),
classes: {
ol: classes.breadcrumbsList,
separator: classes.breadcrumbsSeparator
}
},
breadcrumbs.map((id, i) =>
id === selected
? React.createElement(Typography, {
key: nodeLookup[id].id,
variant: 'subtitle2',
className: classes.breadcrumbsTypography,
children: nodeLookup[id].name.split(':').pop()
})
: React.createElement(Link, {
key: id === 'root' ? 'root' : nodeLookup[id].id,
color: 'inherit',
component: 'button',
variant: 'subtitle2',
underline: 'always',
className: classes.breadcrumbsButton,
TypographyClasses: {
root: classes.breadcrumbsTypography
},
onClick: (e) => handleBreadCrumbClick(e, id === 'root' ? { id: 'root' } : nodeLookup[id]),
children:
id === 'root'
? React.createElement(Root, { className: classes.rootIcon })
: nodeLookup[id].name.split(':').pop()
})
)
),
node.id === 'root'
? React.createElement(
React.Fragment,
null,
React.createElement(
Typography,
{ variant: 'subtitle1', className: classes.currentContentItems },
React.createElement(FormattedMessage, {
id: 'pageExplorerPanel.currentContentItems',
defaultMessage: 'Current Content Items'
})
),
(_a = node.children) === null || _a === void 0
? void 0
: _a
.filter((childNodeId) =>
nodeLookup[String(childNodeId)].name.toLowerCase().includes(keyword.toLowerCase())
)
.map((childNodeId, i) =>
React.createElement(
TreeItemCustom,
Object.assign({}, props, {
key: String(childNodeId) + i,
node: nodeLookup[String(childNodeId)],
isRootChild: node.type === 'root'
})
)
)
)
: React.createElement(TreeItemCustom, {
node: node,
keyword: keyword,
nodeLookup: nodeLookup,
handleScroll: handleScroll,
handleClick: handleClick,
handleOptions: handleOptions
}),
Boolean(optionsMenu.anchorEl) &&
React.createElement(ItemActionsMenu, {
path: optionsMenu.path,
open: Boolean(optionsMenu.anchorEl),
anchorEl: optionsMenu.anchorEl,
onClose: handleClose
})
);
}
export default PreviewPageExplorerPanel;