UNPKG

@craftercms/studio-ui

Version:

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

505 lines (503 loc) 20 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, { useMemo, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import List from '@mui/material/List'; import MenuItem from '@mui/material/MenuItem'; import Menu from '@mui/material/Menu'; import { getHostToGuestBus } from '../../utils/subjects'; import { componentDragEnded, componentDragStarted, contentTypeDropTargetsRequest, pushIcePanelPage, setContentTypeFilter, setPreviewEditMode } from '../../state/actions/preview'; import { DraggablePanelListItem } from '../DraggablePanelListItem/DraggablePanelListItem'; import { useDispatch } from 'react-redux'; import { batchActions } from '../../state/actions/misc'; import { createToolsPanelPage, createWidgetDescriptor } from '../../utils/state'; import { useSelection } from '../../hooks/useSelection'; import SearchBar from '../SearchBar'; import LoadingState from '../LoadingState'; import ErrorBoundary from '../ErrorBoundary'; import EmptyState from '../EmptyState'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import ListItemText, { listItemTextClasses } from '@mui/material/ListItemText'; import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; import IconButton from '@mui/material/IconButton'; import MoreVertRounded from '@mui/icons-material/MoreVertRounded'; import ListItem from '@mui/material/ListItem'; import Accordion from '@mui/material/Accordion'; import AccordionSummary from '@mui/material/AccordionSummary'; import AccordionDetails from '@mui/material/AccordionDetails'; import HourglassEmptyRounded from '@mui/icons-material/HourglassEmptyRounded'; import InfoRounded from '@mui/icons-material/InfoOutlined'; import { ExpandMoreRounded } from '@mui/icons-material'; import Alert, { alertClasses } from '@mui/material/Alert'; import { nou } from '../../utils/object'; import Tooltip from '@mui/material/Tooltip'; import Skeleton from '@mui/material/Skeleton'; import { ItemTypeIcon } from '../ItemTypeIcon'; import Avatar from '@mui/material/Avatar'; import { getAvatarWithIconColors } from '../../utils/contentType'; import { darken, useTheme } from '@mui/material/styles'; const translations = defineMessages({ previewComponentsPanelTitle: { // Translation not used in code but powers i18n for `ui.xml` id: 'previewComponentsPanelTitle', defaultMessage: 'Create Content' }, browse: { id: 'previewComponentsPanel.browse', defaultMessage: 'Browse existing' }, listDropTargets: { id: 'previewComponentsPanel.listDropTargets', defaultMessage: 'List drop targets' }, listInPageInstances: { id: 'previewComponentsPanel.listInPageInstances', defaultMessage: 'Instances on this page' }, filter: { defaultMessage: 'Filter...' } }); export function PreviewComponentsPanel() { const contentTypesBranch = useSelection((state) => state.contentTypes); const allowedTypesData = useSelection((state) => state.preview.guest?.allowedContentTypes); const awaitingGuestCheckIn = nou(allowedTypesData); const contentTypesUpdated = useSelection((state) => state.preview.guest?.contentTypesUpdated); const [keyword, setKeyword] = useState(''); const { formatMessage } = useIntl(); const contentTypeData = useMemo(() => { const allowedTypes = []; const otherTypes = []; const result = { allowedTypes, otherTypes }; if (!contentTypesBranch.byId || !allowedTypesData) return result; const lowerCaseKeyword = keyword.trim().toLowerCase(); const typeLookup = contentTypesBranch.byId; for (const id in typeLookup) { const contentType = typeLookup[id]; if ( // Skip pages contentType.type === 'page' || // Keyword isn't blank and the type name nor id match the keyword (lowerCaseKeyword !== '' && !contentType.name.toLowerCase().includes(lowerCaseKeyword) && !id.toLowerCase().includes(lowerCaseKeyword)) ) { continue; } // if contentType.type === 'component' ... if (allowedTypesData[id]?.embedded || allowedTypesData[id]?.shared) { allowedTypes.push(contentType); } else { otherTypes.push(contentType); } } const sorter = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0); allowedTypes.sort(sorter); otherTypes.sort(sorter); return result; }, [contentTypesBranch.byId, keyword, allowedTypesData]); const dispatch = useDispatch(); const theme = useTheme(); // region Context Menu const [menuContext, setMenuContext] = useState(); const onMenuClose = () => setMenuContext(null); const onBrowseSharedInstancesClicked = () => { dispatch( batchActions([ setContentTypeFilter(menuContext.contentType.id), pushIcePanelPage( createToolsPanelPage( formatMessage({ defaultMessage: 'Existing {name}' }, { name: menuContext.contentType.name }), [createWidgetDescriptor({ id: 'craftercms.components.PreviewBrowseComponentsPanel' })], 'icePanel' ) ) ]) ); }; const onListInPageInstancesClick = () => { dispatch( batchActions([ setContentTypeFilter(menuContext.contentType.id), pushIcePanelPage( createToolsPanelPage( formatMessage({ defaultMessage: 'Instances of {name}' }, { name: menuContext.contentType.name }), [createWidgetDescriptor({ id: 'craftercms.components.PreviewInPageInstancesPanel' })], 'icePanel' ) ) ]) ); }; const onListDropTargetsClick = () => { dispatch( pushIcePanelPage( createToolsPanelPage( formatMessage({ defaultMessage: 'Drop Targets for {name}' }, { name: menuContext.contentType.name }), [createWidgetDescriptor({ id: 'craftercms.components.PreviewDropTargetsPanel' })], 'icePanel' ) ) ); getHostToGuestBus().next(contentTypeDropTargetsRequest({ contentTypeId: menuContext.contentType.id })); }; const onMenuButtonClickHandler = (e, contentType) => { const { backgroundColor, textColor } = getAvatarWithIconColors(contentType.name, theme, darken); setMenuContext({ anchor: e.currentTarget, contentType, backgroundColor, textColor }); }; // endregion // region Drag and Drop const editMode = useSelection((state) => state.preview.editMode); const [isBeingDragged, setIsBeingDragged] = useState(null); const onDragStart = (contentType) => { if (!editMode) { dispatch(setPreviewEditMode({ editMode: true })); } getHostToGuestBus().next({ type: componentDragStarted.type, payload: contentType }); setIsBeingDragged(contentType.id); }; const onDragEnd = () => { getHostToGuestBus().next({ type: componentDragEnded.type }); setIsBeingDragged(null); }; // endregion return React.createElement( ErrorBoundary, null, contentTypesUpdated && React.createElement( Alert, { severity: 'warning', variant: 'outlined', sx: { border: 0 } }, React.createElement(FormattedMessage, { defaultMessage: 'Content type definitions have changed. Please refresh the preview application.' }) ), React.createElement( Box, { sx: { padding: (theme) => `${theme.spacing(1)} ${theme.spacing(1)} 0` } }, React.createElement(SearchBar, { placeholder: formatMessage(translations.filter), showActionButton: Boolean(keyword), onChange: setKeyword, keyword: keyword, autoFocus: true }) ), contentTypesBranch.isFetching ? React.createElement(LoadingState, { title: React.createElement(FormattedMessage, { id: 'componentsPanel.suspenseStateMessage', defaultMessage: 'Retrieving Page Model' }) }) : React.createElement( React.Fragment, null, React.createElement( Box, { sx: { pt: 1 } }, awaitingGuestCheckIn ? React.createElement( Alert, { severity: 'info', variant: 'outlined', icon: React.createElement(HourglassEmptyRounded, null), sx: { border: 0 } }, React.createElement(FormattedMessage, { defaultMessage: 'Waiting for the preview application to load.' }) ) : contentTypeData.allowedTypes.length ? React.createElement( React.Fragment, null, React.createElement( Box, { sx: { display: 'flex', placeContent: 'space-between', alignItems: 'center', px: 1.6, py: 1.5 } }, React.createElement( Typography, { variant: 'overline' }, React.createElement(FormattedMessage, { defaultMessage: 'Compatible Types' }) ), React.createElement( Tooltip, { arrow: true, title: React.createElement(FormattedMessage, { defaultMessage: 'Compatible types are configured in the content model.' }) }, React.createElement( IconButton, { size: 'small' }, React.createElement(InfoRounded, { fontSize: 'small' }) ) ) ), React.createElement( List, { sx: { pt: 0 } }, contentTypeData.allowedTypes.map((contentType) => React.createElement(DraggablePanelListItem, { key: contentType.id, primaryText: contentType.name, avatarColorBase: contentType.id, onDragStart: () => onDragStart(contentType), onDragEnd: onDragEnd, onMenu: (e) => onMenuButtonClickHandler(e, contentType), isBeingDragged: isBeingDragged === contentType.id }) ) ) ) : React.createElement(EmptyState, { sxs: { title: { textAlign: 'center' } }, title: keyword ? React.createElement(FormattedMessage, { defaultMessage: 'No content types were found matching "{keyword}"', values: { keyword } }) : React.createElement(FormattedMessage, { defaultMessage: 'No compatible types were found.' }), subtitle: keyword ? undefined : React.createElement(FormattedMessage, { defaultMessage: 'Developers can configure the content model to add compatible types.' }) }) ), contentTypeData.otherTypes.length > 0 && React.createElement( Accordion, { square: true, disableGutters: true, elevation: 0, sx: { background: 'none' } }, React.createElement( AccordionSummary, { expandIcon: React.createElement(ExpandMoreRounded, null) }, React.createElement( Typography, { variant: 'overline' }, React.createElement(FormattedMessage, { defaultMessage: 'Other Types' }) ) ), React.createElement( AccordionDetails, { sx: { p: 0 } }, React.createElement( Alert, { severity: 'info', variant: 'outlined', sx: { width: '100%', border: 'none', py: 0, [`.${alertClasses.icon}`]: { mr: 1 } } }, React.createElement(FormattedMessage, { defaultMessage: 'Content types not configured for use.' }) ), React.createElement( List, { dense: true }, contentTypeData.otherTypes.map((type) => { const { backgroundColor, textColor } = getAvatarWithIconColors( contentTypesBranch.byId[type.id].name, theme, darken ); return React.createElement( ListItem, { key: type.id }, React.createElement(ListItemText, { sx: { [`.${listItemTextClasses.primary}`]: { display: 'flex', flexDirection: 'row', alignItems: 'center' } }, primary: React.createElement( React.Fragment, null, React.createElement(Box, { component: 'span', sx: { mr: 1, flexShrink: 0, width: '24px', height: '24px', borderRadius: '20px', overflow: 'hidden', backgroundColor, borderColor: textColor, borderStyle: 'solid', borderWidth: '1px', display: 'inline-block' } }), React.createElement( Box, { component: 'span', sx: { flexShrink: 1, flexGrow: 1, textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap' } }, type.name ) ) }), React.createElement( ListItemSecondaryAction, { sx: { right: '10px' } }, React.createElement( IconButton, { edge: 'end', 'aria-label': 'delete', size: 'small', onClick: (e) => onMenuButtonClickHandler(e, type) }, React.createElement(MoreVertRounded, null) ) ) ); }) ) ) ) ), React.createElement( Menu, { open: Boolean(menuContext), anchorEl: menuContext?.anchor, onClose: onMenuClose, slotProps: { paper: { sx: { maxWidth: 350, minWidth: 200 } } } }, React.createElement( Box, { component: 'header', sx: { px: 2, py: 1, mb: 1, display: 'block', maxWidth: '100%', borderWidth: 1, borderColor: 'divider', borderBottomStyle: 'solid' } }, React.createElement( Box, { component: 'section', sx: { display: 'flex', placeContent: 'space-between' } }, React.createElement( Box, null, React.createElement( Typography, { component: 'div', variant: 'caption', color: 'textSecondary', title: menuContext?.contentType.id, sx: { wordBreak: 'break-all' } }, menuContext?.contentType.id ?? React.createElement(Skeleton, { variant: 'text' }) ), React.createElement( Typography, null, menuContext?.contentType.name ?? React.createElement(Skeleton, { variant: 'text', width: '100px' }) ) ), React.createElement( Avatar, { component: 'div', sx: { backgroundColor: menuContext?.backgroundColor, ml: 1 } }, React.createElement(ItemTypeIcon, { fontSize: 'medium', item: { systemType: menuContext?.contentType.type ?? '', mimeType: '' }, sx: { color: menuContext?.textColor } }) ) ), React.createElement( Typography, { variant: 'body2', color: 'textSecondary', sx: { mt: 1 } }, React.createElement(FormattedMessage, { defaultMessage: 'The model is configured for {modes}', values: { modes: Object.keys(allowedTypesData?.[menuContext?.contentType.id] ?? {}) .map((mode) => (mode === 'sharedExisting' ? 'existing shared' : mode)) .join(', ') } }) ) ), React.createElement( MenuItem, { onClick: onListInPageInstancesClick }, formatMessage(translations.listInPageInstances) ), React.createElement(MenuItem, { onClick: onBrowseSharedInstancesClicked }, formatMessage(translations.browse)), React.createElement(MenuItem, { onClick: onListDropTargetsClick }, formatMessage(translations.listDropTargets)) ) ); } export default PreviewComponentsPanel;