@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
378 lines (376 loc) • 14.6 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, useRef, useState } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { useDispatch } from 'react-redux';
import { useActiveSiteId } from '../../hooks/useActiveSiteId';
import { useSpreadState } from '../../hooks/useSpreadState';
import { menuInitialState, menuOptions } from './utils';
import ContextMenu from '../ContextMenu';
import {
closeConfirmDialog,
fetchContentVersion,
historyDialogUpdate,
showCompareVersionsDialog,
showConfirmDialog,
showPreviewDialog,
showViewVersionDialog
} from '../../state/actions/dialogs';
import translations from './translations';
import { batchActions } from '../../state/actions/misc';
import { fetchContentTypes } from '../../state/actions/preview';
import { fetchContentByCommitId } from '../../services/content';
import { getEditorMode, isImage, isPdfDocument, isPreviewable, isVideo } from '../PathNavigator/utils';
import {
compareBothVersions,
compareToPreviousVersion,
revertContent,
revertToPreviousVersion,
versionsChangeItem,
versionsChangeLimit,
versionsChangePage
} from '../../state/actions/versions';
import { asDayMonthDateTime } from '../../utils/datetime';
import DialogBody from '../DialogBody/DialogBody';
import SingleItemSelector from '../SingleItemSelector';
import VersionList from '../VersionList';
import DialogFooter from '../DialogFooter/DialogFooter';
import { HistoryDialogPagination } from './HistoryDialogPagination';
import { historyStyles } from './HistoryDialog';
import useSelection from '../../hooks/useSelection';
import useFetchSandboxItems from '../../hooks/useFetchSandboxItems';
import { UNDEFINED } from '../../utils/constants';
import { ErrorBoundary } from '../ErrorBoundary';
import { LoadingState } from '../LoadingState';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
export function HistoryDialogContainer(props) {
const { versionsBranch } = props;
const { count, page, limit, current, rootPath, isConfig } = versionsBranch;
useFetchSandboxItems([versionsBranch.item.path]);
// TODO: It'd be best for the dialog to directly receive a live item. Must change versions branch to only hold the path.
const item = useSelection((state) => state.content.itemsByPath[versionsBranch.item.path]);
const path = item?.path ?? '';
const [openSelector, setOpenSelector] = useState(false);
const { formatMessage } = useIntl();
const { classes } = historyStyles();
const dispatch = useDispatch();
const site = useActiveSiteId();
const timeoutRef = useRef(null);
const isItemPreviewable = isPreviewable(item);
// Item may be null for config items in config management.
const isDiffSupported = ['page', 'component', 'taxonomy'].includes(item?.systemType);
const [compareMode, setCompareMode] = useState(false);
const [selectedCompareVersions, setSelectedCompareVersions] = useState([]);
const [menu, setMenu] = useSpreadState(menuInitialState);
const handleOpenMenu = useCallback(
(anchorEl, version, isCurrent = false, initialCommit) => {
const hasOptions = ['page', 'component', 'taxonomy'].includes(item.systemType);
const contextMenuOptions = {};
Object.entries(menuOptions).forEach(([key, value]) => {
contextMenuOptions[key] = {
id: value.id,
label: formatMessage(value.label, value.values),
icon: value.id === 'compareToPrevious' ? { id: '@mui/icons-material/ArrowDownwardRounded' } : null
};
});
const sections = [];
if (isItemPreviewable) {
sections.push([contextMenuOptions.view]);
}
if (count > 1) {
if (hasOptions) {
if (initialCommit) {
sections.push([contextMenuOptions.compareTo, contextMenuOptions.compareToCurrent]);
} else if (isCurrent) {
sections.push([contextMenuOptions.compareTo, contextMenuOptions.compareToPrevious]);
} else {
sections.push([
contextMenuOptions.compareTo,
contextMenuOptions.compareToCurrent,
contextMenuOptions.compareToPrevious
]);
}
}
if (!item.stateMap.locked && (item.availableActionsMap.revert || isConfig) && version.revertible) {
sections.push([isCurrent ? contextMenuOptions.revertToPrevious : contextMenuOptions.revertToThisVersion]);
}
}
setMenu({
sections,
anchorEl,
activeItem: version
});
},
[
item?.systemType,
item?.availableActionsMap.revert,
count,
setMenu,
formatMessage,
isConfig,
item?.stateMap.locked,
isItemPreviewable
]
);
const hasMenuOptions = isItemPreviewable || count > 1;
const compareVersionDialogWithActions = () => {
setCompareMode(false);
setSelectedCompareVersions([]);
return showCompareVersionsDialog({
disableItemSwitching: true
});
};
const handleViewItem = (version) => {
const versionPath = Boolean(version.path) && path !== version.path ? version.path : path;
if (isDiffSupported) {
dispatch(
batchActions([
fetchContentTypes(),
fetchContentVersion({ path: versionPath, versionNumber: version.versionNumber }),
showViewVersionDialog({})
])
);
} else if (isItemPreviewable) {
fetchContentByCommitId(site, versionPath, version.versionNumber).subscribe((content) => {
const image = isImage(item);
const video = isVideo(item);
const pdf = isPdfDocument(item.mimeType);
dispatch(
showPreviewDialog({
type: image ? 'image' : video ? 'video' : pdf ? 'pdf' : 'editor',
title: item.label,
[image || video || pdf ? 'url' : 'content']: content,
mode: image || video || pdf ? UNDEFINED : getEditorMode(item),
path: item.path,
subtitle: `v.${version.versionNumber}`,
...(video ? { mimeType: item.mimeType } : {})
})
);
});
}
};
const compareBoth = (selected) => {
const selectedADate = new Date(versionsBranch.byId[selected[0]].modifiedDate);
const selectedBDate = new Date(versionsBranch.byId[selected[1]].modifiedDate);
// When comparing versions, we want to show what the new version did to the old. For that, we check for the most
// recent version and add it first in the list of versions to compare.
dispatch(
batchActions([
fetchContentTypes(),
compareBothVersions({
versions: selectedADate > selectedBDate ? [selected[0], selected[1]] : [selected[1], selected[0]]
}),
compareVersionDialogWithActions()
])
);
};
const compareToPrevious = (versionNumber) => {
dispatch(
batchActions([
fetchContentTypes(),
compareToPreviousVersion({ id: versionNumber }),
compareVersionDialogWithActions()
])
);
};
const revertToPrevious = (activeItem) => {
const previousBranch = getPreviousBranch(activeItem);
dispatch(
showConfirmDialog({
title: formatMessage(translations.confirmRevertTitle),
body: formatMessage(translations.confirmRevertBody, {
versionTitle: asDayMonthDateTime(previousBranch.modifiedDate)
}),
onCancel: closeConfirmDialog(),
onOk: batchActions([closeConfirmDialog(), revertToPreviousVersion({ path, id: activeItem.versionNumber })])
})
);
};
const getPreviousBranch = (currentBranch) => {
const versions = versionsBranch.versions;
const currentIndex = versions.findIndex((branch) => branch.versionNumber === currentBranch.versionNumber);
return versions[currentIndex + 1] ?? null;
};
const revertTo = (activeItem) => {
dispatch(
showConfirmDialog({
title: formatMessage(translations.confirmRevertTitle),
body: formatMessage(translations.confirmRevertBody, {
versionTitle: asDayMonthDateTime(activeItem.modifiedDate)
}),
onCancel: closeConfirmDialog(),
onOk: batchActions([closeConfirmDialog(), revertContent({ path, versionNumber: activeItem.versionNumber })])
})
);
};
const handleContextMenuClose = () => {
setMenu({
anchorEl: null,
activeItem: null
});
};
const handleContextMenuItemClicked = (option) => {
const activeItem = menu.activeItem;
setMenu(menuInitialState);
switch (option) {
case 'view': {
handleViewItem(activeItem);
break;
}
case 'compareTo': {
setCompareMode(true);
onVersionSelected(activeItem);
break;
}
case 'compareToCurrent': {
compareBoth([current, activeItem.versionNumber]);
break;
}
case 'compareToPrevious': {
compareToPrevious(activeItem.versionNumber);
break;
}
case 'revertToPrevious': {
revertToPrevious(activeItem);
break;
}
case 'revertToThisVersion': {
revertTo(activeItem);
break;
}
default:
break;
}
};
// A user clicking too eagerly to jump to a much later page may end up clicking
// the backdrop by mistake when the number of versions goes from pushing the height
// of the dialog away from its minimum and then on the next page back to the minimum.
// The timeout gives the user a chance to catch up with the change in the position of
// the pagination button and/or realise he's reached the last page without closing the
// dialog unintentionally.
const temporaryBackdropClickDisable = () => {
clearTimeout(timeoutRef.current);
dispatch(historyDialogUpdate({ hasPendingChanges: true }));
timeoutRef.current = setTimeout(() => dispatch(historyDialogUpdate({ hasPendingChanges: false })), 700);
};
const onPageChanged = (nextPage) => {
temporaryBackdropClickDisable();
dispatch(versionsChangePage({ page: nextPage }));
};
const onRowsPerPageChange = (limit) => {
temporaryBackdropClickDisable();
dispatch(versionsChangeLimit({ limit }));
};
const onVersionSelected = (version) => {
const newSelectedVersions = selectedCompareVersions.includes(version.versionNumber)
? selectedCompareVersions.filter((v) => v !== version.versionNumber)
: [...selectedCompareVersions, version.versionNumber];
if (newSelectedVersions.length === 2) {
compareBoth(newSelectedVersions);
} else {
setSelectedCompareVersions(newSelectedVersions);
}
};
return React.createElement(
React.Fragment,
null,
React.createElement(
DialogBody,
{ className: classes.dialogBody, minHeight: true },
React.createElement(
Box,
{ sx: { mb: 2, display: 'flex', alignItems: 'center' } },
React.createElement(SingleItemSelector, {
label: React.createElement(FormattedMessage, { id: 'words.item', defaultMessage: 'Item' }),
open: openSelector,
disabled: isConfig,
onClose: () => setOpenSelector(false),
onDropdownClick: () => setOpenSelector(!openSelector),
rootPath: rootPath,
selectedItem: item,
onItemClicked: (item) => {
setOpenSelector(false);
dispatch(versionsChangeItem({ item }));
setSelectedCompareVersions([]);
setCompareMode(false);
}
}),
isDiffSupported &&
count > 1 &&
React.createElement(FormControlLabel, {
control: React.createElement(Switch, { color: 'primary', checked: compareMode }),
label: React.createElement(FormattedMessage, { defaultMessage: 'Compare' }),
labelPlacement: 'start',
onChange: (e) => {
setCompareMode(e.currentTarget.checked);
}
})
),
React.createElement(
ErrorBoundary,
null,
versionsBranch.isFetching
? React.createElement(LoadingState, null)
: versionsBranch.versions &&
React.createElement(VersionList, {
versions: versionsBranch.versions,
onOpenMenu: hasMenuOptions && !compareMode ? handleOpenMenu : null,
onItemClick: compareMode ? (version) => onVersionSelected(version) : handleViewItem,
selected: selectedCompareVersions,
current: current,
isSelectMode: compareMode
})
)
),
React.createElement(
DialogFooter,
{ classes: { root: classes.dialogFooter } },
count > 0 &&
React.createElement(HistoryDialogPagination, {
count: count,
page: page,
rowsPerPage: limit,
onPageChanged: onPageChanged,
onRowsPerPageChange: onRowsPerPageChange
})
),
Boolean(menu.anchorEl) &&
React.createElement(ContextMenu, {
open: true,
anchorEl: menu.anchorEl,
onClose: handleContextMenuClose,
options: menu.sections,
onMenuItemClicked: handleContextMenuItemClicked
})
);
}