@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
505 lines (503 loc) • 20 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, { 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;