UNPKG

@craftercms/studio-ui

Version:

Services, components, models & utils to build CrafterCMS authoring extensions.

816 lines (814 loc) 27.2 kB
/* * 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;