@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
1,242 lines (1,240 loc) • 55.5 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, { useEffect, useRef, useState } from 'react';
import {
allowedContentTypesUpdate,
changeCurrentUrl,
clearSelectedZones,
clearSelectForEdit,
contentTypeDropTargetsResponse,
contentTypesResponse,
deleteItemOperation,
deleteItemOperationComplete,
deleteItemOperationFailed,
duplicateItemOperation,
duplicateItemOperationComplete,
duplicateItemOperationFailed,
errorPageCheckIn,
fetchContentTypes,
fetchGuestModel,
fetchGuestModelComplete,
fetchGuestModelsComplete,
fetchPrimaryGuestModelComplete,
guestCheckIn,
guestCheckOut,
guestModelUpdated,
guestSiteLoad,
hostCheckIn,
hotKey,
iceZoneSelected,
initPreviewConfig,
initRichTextEditorConfig,
insertComponentOperation,
insertItemOperation,
insertItemOperationComplete,
insertItemOperationFailed,
insertOperationComplete,
insertOperationFailed,
instanceDragBegun,
instanceDragEnded,
moveItemOperation,
moveItemOperationComplete,
moveItemOperationFailed,
reloadRequest,
requestEdit,
requestWorkflowCancellationDialog,
requestWorkflowCancellationDialogOnResult,
selectForEdit,
setContentTypeDropTargets,
setItemBeingDragged,
setPreviewEditMode,
showEditDialog as showEditDialogAction,
snackGuestMessage,
sortItemOperation,
sortItemOperationComplete,
sortItemOperationFailed,
toggleEditModePadding,
trashed,
updateFieldValueOperation,
updateFieldValueOperationComplete,
updateFieldValueOperationFailed,
updateRteConfig
} from '../../state/actions/preview';
import {
deleteItem,
duplicateItem,
fetchContentInstance,
fetchContentInstanceDescriptor,
fetchItemsByPath,
fetchSandboxItem as fetchSandboxItemService,
fetchWorkflowAffectedItems,
insertComponent,
insertInstance,
insertItem,
moveItem,
sortItem,
updateField,
writeInstance
} from '../../services/content';
import { filter, map, switchMap, take, takeUntil } from 'rxjs/operators';
import { BehaviorSubject, forkJoin, of } from 'rxjs';
import { useIntl } from 'react-intl';
import { getGuestToHostBus, getHostToGuestBus, getHostToHostBus } from '../../utils/subjects';
import { useDispatch, useStore } from 'react-redux';
import { nnou } from '../../utils/object';
import { findParentModelId, getModelIdFromInheritedField, isInheritedField } from '../../utils/model';
import RubbishBin from '../RubbishBin/RubbishBin';
import { useSnackbar } from 'notistack';
import {
getStoredClipboard,
getStoredEditModeChoice,
getStoredEditModePadding,
getStoredHighlightModeChoice,
getStoredOutdatedXBValidationDate,
removeStoredClipboard,
setStoredOutdatedXBValidationDate
} from '../../utils/state';
import {
fetchSandboxItem,
reloadDetailedItem,
restoreClipboard,
unlockItem,
updateItemsByPath
} from '../../state/actions/content';
import EditFormPanel from '../EditFormPanel/EditFormPanel';
import {
createModelHierarchyDescriptorMap,
getInheritanceParentIdsForField,
getNumOfMenuOptionsForItem,
isItemLockedForMe,
normalizeModel,
normalizeModelsLookup,
parseContentXML
} from '../../utils/content';
import moment from 'moment-timezone';
import IconButton from '@mui/material/IconButton';
import { useSelection } from '../../hooks/useSelection';
import { usePreviewState } from '../../hooks/usePreviewState';
import { useContentTypes } from '../../hooks/useContentTypes';
import { useActiveUser } from '../../hooks/useActiveUser';
import { usePreviewNavigation } from '../../hooks/usePreviewNavigation';
import { useActiveSite } from '../../hooks/useActiveSite';
import { getPathFromPreviewURL, processPathMacros, withIndex } from '../../utils/path';
import {
closeItemMegaMenu,
closeSingleFileUploadDialog,
itemMegaMenuClosed,
rtePickerActionResult,
showEditDialog,
showItemMegaMenu,
showRtePickerActions,
showSingleFileUploadDialog,
showWorkflowCancellationDialog,
workflowCancellationDialogClosed
} from '../../state/actions/dialogs';
import { UNDEFINED } from '../../utils/constants';
import { useCurrentPreviewItem } from '../../hooks/useCurrentPreviewItem';
import { useSiteUIConfig } from '../../hooks/useSiteUIConfig';
import { useRTEConfig } from '../../hooks/useRTEConfig';
import { guestMessages } from '../../assets/guestMessages';
import { useEnhancedDialogState } from '../../hooks/useEnhancedDialogState';
import KeyboardShortcutsDialog from '../KeyboardShortcutsDialog';
import { previewKeyboardShortcuts } from '../../assets/keyboardShortcuts';
import {
contentEvent,
contentTypeCreated,
contentTypeDeleted,
contentTypeUpdated,
lockContentEvent,
pluginInstalled,
pluginUninstalled,
showSystemNotification
} from '../../state/actions/system';
import useSpreadState from '../../hooks/useSpreadState';
import useUpdateRefs from '../../hooks/useUpdateRefs';
import { useHotkeys } from 'react-hotkeys-hook';
import { batchActions, dispatchDOMEvent, editContentTypeTemplate } from '../../state/actions/misc';
import RefreshRounded from '@mui/icons-material/RefreshRounded';
import { getPersonFullName } from '../SiteDashboard';
import { useTheme } from '@mui/material/styles';
import { createCustomDocumentEventListener } from '../../utils/dom';
import BrowseFilesDialog from '../BrowseFilesDialog';
import DataSourcesActionsList from '../DataSourcesActionsList/DataSourcesActionsList';
import { editControllerActionCreator, itemActionDispatcher } from '../../utils/itemActions';
import useEnv from '../../hooks/useEnv';
import { getOffsetLeft, getOffsetTop } from '@mui/material/Popover';
import { isSameDay } from '../../utils/datetime';
import minGuestVersion from './minGuestVersion';
import { versionStringToInt } from '../../utils/string';
const issueDescriptorRequest = (props) => {
const {
site,
path,
contentTypes,
requestedSourceMapPaths,
flatten = true,
dispatch,
completeActionCreator,
permissions
} = props;
const hostToGuest$ = getHostToGuestBus();
const guestToHost$ = getGuestToHostBus();
fetchContentInstanceDescriptor(site, path, { flatten }, contentTypes)
.pipe(
// If another check in comes while loading, this request should be cancelled.
// This may happen if navigating rapidly from one page to another (guest-side).
takeUntil(guestToHost$.pipe(filter(({ type }) => [guestCheckIn.type, guestCheckOut.type].includes(type)))),
switchMap((modelResponse) => {
let requests = [];
let sandboxItemPaths = []; // Used to collect the paths to fetch the sandbox items corresponding to the Content Instances.
let sandboxItemPathLookup = {};
Object.values(modelResponse.modelLookup).forEach((model) => {
if (model.craftercms.path) {
sandboxItemPaths.push(model.craftercms.path);
sandboxItemPathLookup[model.craftercms.path] = true;
Object.values(model.craftercms.sourceMap).forEach((path) => {
if (!sandboxItemPathLookup[path]) {
sandboxItemPathLookup[path] = true;
sandboxItemPaths.push(path);
}
if (!requestedSourceMapPaths.current[path]) {
requestedSourceMapPaths.current[path] = true;
requests.push(fetchContentInstance(site, path, contentTypes));
}
});
}
});
Object.keys(modelResponse.unflattenedPaths).forEach((path) => {
sandboxItemPaths.push(path);
requests.push(fetchContentInstance(site, path, contentTypes));
});
return forkJoin({
sandboxItems: fetchItemsByPath(site, sandboxItemPaths),
modelResponse: requests.length
? forkJoin(requests).pipe(
map((response) => {
response.forEach((contentInstance) => {
if (contentInstance.craftercms.path in modelResponse.unflattenedPaths) {
// Complete the object reference with the freshly-fetched instance and add it to the modelLookup.
// This relies on object references inside the lookup objects being referenced by the unflattenedPaths.
// i.e. unflattenedPaths is a shortcut to the objects withing the guts of the models on the modelLookup.
modelResponse.modelLookup[contentInstance.craftercms.id] = Object.assign(
modelResponse.unflattenedPaths[contentInstance.craftercms.path],
contentInstance
);
} else {
modelResponse.modelLookup[contentInstance.craftercms.id] = contentInstance;
}
});
return modelResponse;
})
)
: of(modelResponse)
});
})
)
.subscribe(({ sandboxItems, modelResponse }) => {
const { model, modelLookup } = modelResponse;
const normalizedModels = normalizeModelsLookup(modelLookup);
const hierarchyMap = createModelHierarchyDescriptorMap(normalizedModels, contentTypes);
const normalizedModel = normalizedModels[model.craftercms.id];
const modelIdByPath = {};
Object.values(modelLookup).forEach((model) => {
if (
// Embedded components don't have a path.
model.craftercms.path &&
// Items that weren't flattened and their path doesn't contain their id, would come with a `null` id.
model.craftercms.id &&
// Not-flattened items whose file name is their id, would have id/path filled up but the rest of props null.
// Technically, just this line might be sufficient. Could evaluate remove the id check.
model.craftercms.contentTypeId
) {
modelIdByPath[model.craftercms.path] = model.craftercms.id;
}
});
dispatch(
batchActions([
completeActionCreator({
model: normalizedModel,
modelLookup: normalizedModels,
modelIdByPath: modelIdByPath,
hierarchyMap
}),
updateItemsByPath({ items: sandboxItems })
])
);
hostToGuest$.next(
fetchGuestModelComplete({
path,
model: normalizedModel,
modelLookup: normalizedModels,
hierarchyMap,
modelIdByPath: modelIdByPath,
sandboxItems,
permissions
})
);
});
};
const showGuestCompatibilityMessage = ({
siteId,
username,
mainMessage,
messageAppend,
enqueueSnackbar,
consoleMessageShown
}) => {
const xbOutdatedValidationDate = getStoredOutdatedXBValidationDate(siteId, username);
// Show only if message has not been shown today or ever.
if (!xbOutdatedValidationDate || !isSameDay(xbOutdatedValidationDate, new Date())) {
enqueueSnackbar([mainMessage, messageAppend].join(' '), { variant: 'warning' });
setStoredOutdatedXBValidationDate(siteId, username, new Date());
}
// Only show once per tab session (full reload will show it once again).
if (!consoleMessageShown) {
console.log(
`%c(i) Please check your @craftercms/experience-builder package version. \n See https://craftercms.com/docs/current/reference/api/javascript-sdk.html`,
'color: #00f'
);
}
};
const dataSourceActionsListInitialState = {
show: false,
rect: null,
items: []
};
export function PreviewConcierge(props) {
const dispatch = useDispatch();
const store = useStore();
const { id: siteId, uuid } = useActiveSite() ?? {};
const user = useActiveUser();
const username = user?.username;
const { guest, editMode, highlightMode, editModePadding, icePanelWidth, toolsPanelWidth, hostSize, showToolsPanel } =
usePreviewState();
const item = useCurrentPreviewItem();
const { currentUrlPath } = usePreviewNavigation();
const contentTypes = useContentTypes();
const contentTypes$Ref = useRef();
const { authoringBase, guestBase, xsrfArgument } = useSelection((state) => state.env);
const priorState = useRef({ site: siteId });
const { enqueueSnackbar } = useSnackbar();
const { formatMessage } = useIntl();
const models = guest?.models;
const modelIdByPath = guest?.modelIdByPath;
const hierarchyMap = guest?.hierarchyMap;
const requestedSourceMapPaths = useRef({});
const currentItemPath = guest?.path;
const uiConfig = useSiteUIConfig();
const { cdataEscapedFieldPatterns } = uiConfig;
const rteConfig = useRTEConfig();
const keyboardShortcutsDialogState = useEnhancedDialogState();
const theme = useTheme();
const browseFilesDialogState = useEnhancedDialogState();
const [browseFilesDialogPath, setBrowseFilesDialogPath] = useState('/');
const [browseFilesDialogMimeTypes, setBrowseFilesDialogMimeTypes] = useState([]);
const [dataSourceActionsListState, setDataSourceActionsListState] = useSpreadState(dataSourceActionsListInitialState);
const toggleEditMode = (nextHighlightMode) => {
dispatch(
setPreviewEditMode({
// If switching from highlight modes (all vs move), we just want to switch modes without turning off edit mode.
editMode: nextHighlightMode !== highlightMode ? true : !editMode,
highlightMode: nextHighlightMode
})
);
};
const env = useEnv();
const xbCompatConsoleWarningPrintedRef = useRef(false);
const upToDateRefs = useUpdateRefs({
store,
item,
theme,
guest,
models,
user,
siteId,
dispatch,
guestBase,
rteConfig,
contentTypes,
xsrfArgument,
hierarchyMap,
highlightMode,
modelIdByPath,
formatMessage,
authoringBase,
currentUrlPath,
enqueueSnackbar,
editModePadding,
cdataEscapedFieldPatterns,
toggleEditMode,
keyboardShortcutsDialogState,
setDataSourceActionsListState,
showToolsPanel,
toolsPanelWidth,
browseFilesDialogState,
onShortCutKeypress(event) {
const key = event.key;
switch (key) {
case 'e':
upToDateRefs.current.toggleEditMode('all');
break;
case 'm':
upToDateRefs.current.toggleEditMode('move');
break;
case 'p':
upToDateRefs.current.dispatch(toggleEditModePadding());
break;
case '?':
upToDateRefs.current.keyboardShortcutsDialogState.onOpen();
break;
case 'r':
getHostToGuestBus().next(reloadRequest());
getHostToHostBus().next(reloadRequest());
break;
case 'E':
upToDateRefs.current.item &&
dispatch(
showEditDialog({
site: upToDateRefs.current.siteId,
path: upToDateRefs.current.guest.path,
readonly:
!upToDateRefs.current.item.availableActionsMap.edit ||
isItemLockedForMe(upToDateRefs.current.item, upToDateRefs.current.user.username),
authoringBase: upToDateRefs.current.authoringBase
})
);
break;
case 'a':
if (store.getState().dialogs.itemMegaMenu.open) {
dispatch(closeItemMegaMenu());
} else if (upToDateRefs.current.item) {
let top, left;
let menuButton = document.querySelector('#previewAddressBarActionsMenuButton');
if (menuButton) {
let anchorRect = menuButton.getBoundingClientRect();
top = anchorRect.top + getOffsetTop(anchorRect, 'top');
left = anchorRect.left + getOffsetLeft(anchorRect, 'left');
} else {
top = 80;
left = (upToDateRefs.current.showToolsPanel ? upToDateRefs.current.toolsPanelWidth : 0) + 20;
}
let path = upToDateRefs.current.item.path;
if (path === '/site/website') {
path = withIndex(path);
}
dispatch(
showItemMegaMenu({
path: path,
anchorReference: 'anchorPosition',
anchorPosition: { top, left },
loaderItems: getNumOfMenuOptionsForItem(item)
})
);
}
break;
}
},
env,
contentTypes$: contentTypes$Ref.current
});
const onRtePickerResult = (payload) => {
const hostToGuest$ = getHostToGuestBus();
hostToGuest$.next({
type: rtePickerActionResult.type,
payload
});
};
useEffect(() => {
if (nnou(uiConfig.xml)) {
const storedEditMode = getStoredEditModeChoice(username, uuid);
const storedHighlightMode = getStoredHighlightModeChoice(username, uuid);
const storedPaddingMode = getStoredEditModePadding(username);
dispatch(initPreviewConfig({ configXml: uiConfig.xml, storedEditMode, storedHighlightMode, storedPaddingMode }));
}
}, [uiConfig.xml, username, uuid, dispatch]);
// Legacy Guest pencil repaint - When the guest screen size changes, pencils need to be repainted.
useEffect(() => {
if (editMode) {
let timeout = setTimeout(() => {
getHostToGuestBus().next({ type: 'REPAINT_PENCILS' });
}, 500);
return () => {
clearTimeout(timeout);
};
}
}, [icePanelWidth, toolsPanelWidth, hostSize, editMode, showToolsPanel]);
// Send editMode changes to guest
useEffect(() => {
// FYI. Path navigator refresh triggers this effect too due to item changing.
if (item) {
getHostToGuestBus().next(setPreviewEditMode({ editMode }));
}
}, [item, editMode]);
// Fetch active item
useEffect(() => {
if (currentItemPath && siteId) {
dispatch(fetchSandboxItem({ path: currentItemPath }));
}
}, [dispatch, currentItemPath, siteId]);
// Update rte config
useEffect(() => {
if (rteConfig) {
// @ts-ignore - TODO: type action accordingly
getHostToGuestBus().next(updateRteConfig({ rteConfig }));
}
}, [rteConfig]);
// Retrieve stored site clipboard, retrieve stored tools panel page.
useEffect(() => {
const localClipboard = getStoredClipboard(uuid, username);
if (localClipboard) {
let hours = moment().diff(moment(localClipboard.timestamp), 'hours');
if (hours >= 24) {
removeStoredClipboard(uuid, username);
} else {
dispatch(
restoreClipboard({
type: localClipboard.type,
paths: localClipboard.paths,
sourcePath: localClipboard.sourcePath
})
);
}
}
}, [dispatch, uuid, username]);
// Post content types
useEffect(() => {
contentTypes && getHostToGuestBus().next(contentTypesResponse({ contentTypes: Object.values(contentTypes) }));
if (!contentTypes$Ref.current) contentTypes$Ref.current = new BehaviorSubject(contentTypes);
contentTypes$Ref.current.next(contentTypes);
}, [contentTypes]);
// region guestToHost$ subscription
useEffect(() => {
const hostToGuest$ = getHostToGuestBus();
const guestToHost$ = getGuestToHostBus();
const hostToHost$ = getHostToHostBus();
const updatedModifiedItem = (path) => {
upToDateRefs.current.dispatch(
reloadDetailedItem({
path
})
);
};
const guestToHostSubscription = guestToHost$.subscribe((action) => {
// region const { ... } = upToDateRefs.current
const {
siteId,
models,
dispatch,
guestBase,
contentTypes,
hierarchyMap,
authoringBase,
formatMessage,
modelIdByPath,
enqueueSnackbar,
user,
env
} = upToDateRefs.current;
// endregion
const { type, payload } = action;
const permissions = user?.permissionsBySite[siteId];
const contentTypes$ = upToDateRefs.current.contentTypes$.pipe(
filter((contentTypes) => Boolean(contentTypes)),
take(1)
);
if (type === guestCheckIn.type && !payload.__CRAFTERCMS_GUEST_LANDING__) {
const studioVersionStr = env.version;
const guestVersionStr = payload.version;
const guestMinStudioVersionStr = payload.minStudioVersion;
if (
// If we don't receive the expected check-in mechanics, or the SDK version is less than the minimum
// required by Studio, show the Snack requesting update of the SDK.
!guestVersionStr ||
!guestMinStudioVersionStr ||
versionStringToInt(guestVersionStr) < versionStringToInt(minGuestVersion)
) {
showGuestCompatibilityMessage({
siteId,
enqueueSnackbar,
username: user.username,
mainMessage: formatMessage(guestMessages.outdatedGuestVersion),
messageAppend: formatMessage(guestMessages.compatibilityMessageAppend),
consoleMessageShown: xbCompatConsoleWarningPrintedRef.current
});
xbCompatConsoleWarningPrintedRef.current = true;
} else if (
// Check Guest's report of minimum Studio version it is compatible with.
versionStringToInt(studioVersionStr) < versionStringToInt(guestMinStudioVersionStr)
) {
showGuestCompatibilityMessage({
siteId,
enqueueSnackbar,
username: user.username,
mainMessage: formatMessage(guestMessages.incompatibleGuestVersion),
messageAppend: formatMessage(guestMessages.compatibilityMessageAppend),
consoleMessageShown: xbCompatConsoleWarningPrintedRef.current
});
xbCompatConsoleWarningPrintedRef.current = true;
}
}
switch (type) {
// region Legacy preview sites messages
case guestSiteLoad.type: {
const { url, location } = payload;
const path = getPathFromPreviewURL(url);
dispatch(guestCheckIn({ location, site: siteId, path }));
contentTypes$.subscribe((contentTypes) => {
issueDescriptorRequest({
site: siteId,
path,
contentTypes,
requestedSourceMapPaths,
dispatch,
completeActionCreator: fetchPrimaryGuestModelComplete,
permissions
});
});
break;
}
case 'ICE_ZONE_ON': {
dispatch(
showEditDialog({
path: payload.itemId,
authoringBase,
site: siteId,
iceGroupId: payload.iceId || UNDEFINED,
modelId: payload.embeddedItemId || UNDEFINED,
isHidden: Boolean(payload.embeddedItemId)
})
);
break;
}
case 'IS_REVIEWER': {
getHostToGuestBus().next({ type: 'REPAINT_PENCILS' });
break;
}
case 'CHECK_OUT_GUEST': {
const path = getPathFromPreviewURL(payload.url);
dispatch(guestCheckOut({ path }));
break;
}
// endregion
case guestCheckIn.type: {
getHostToGuestBus().next(
hostCheckIn({
editMode: false,
username: upToDateRefs.current.user.username,
highlightMode: upToDateRefs.current.highlightMode,
authoringBase: upToDateRefs.current.authoringBase,
site: upToDateRefs.current.siteId,
editModePadding: upToDateRefs.current.editModePadding,
rteConfig: upToDateRefs.current.rteConfig ?? {}
})
);
dispatch(guestCheckIn(payload));
if (payload.__CRAFTERCMS_GUEST_LANDING__) {
nnou(siteId) && dispatch(changeCurrentUrl('/'));
} else {
const path = payload.path;
contentTypes$.subscribe((contentTypes) => {
hostToGuest$.next(contentTypesResponse({ contentTypes: Object.values(contentTypes) }));
issueDescriptorRequest({
site: siteId,
path,
contentTypes,
requestedSourceMapPaths,
dispatch,
completeActionCreator: fetchPrimaryGuestModelComplete,
permissions
});
});
}
break;
}
case fetchGuestModel.type: {
if (payload.path?.startsWith('/')) {
contentTypes$.subscribe((contentTypes) => {
issueDescriptorRequest({
site: siteId,
path: payload.path,
contentTypes,
requestedSourceMapPaths,
dispatch,
completeActionCreator: fetchGuestModelsComplete,
permissions
});
});
} else {
return console.warn(`Ignoring FETCH_GUEST_MODEL request since "${payload.path}" is not a valid path.`);
}
break;
}
case guestCheckOut.type: {
requestedSourceMapPaths.current = {};
dispatch(action);
break;
}
case sortItemOperation.type: {
const { fieldId, currentIndex, targetIndex } = payload;
let { modelId, parentModelId } = payload;
const path = models[modelId ?? parentModelId].craftercms.path;
if (isInheritedField(models[modelId], fieldId)) {
modelId = getModelIdFromInheritedField(models[modelId], fieldId, upToDateRefs.current.modelIdByPath);
parentModelId = findParentModelId(modelId, upToDateRefs.current.hierarchyMap, models);
}
sortItem(
siteId,
modelId,
fieldId,
currentIndex,
targetIndex,
models[parentModelId ? parentModelId : modelId].craftercms.path
).subscribe({
next({ updatedDocument }) {
const updatedModels = {};
parseContentXML(
updatedDocument,
parentModelId ? models[parentModelId].craftercms.path : models[modelId].craftercms.path,
contentTypes,
updatedModels
);
dispatch(guestModelUpdated({ model: normalizeModel(updatedModels[modelId]) }));
issueDescriptorRequest({
site: siteId,
path: path ?? models[parentModelId].craftercms.path,
contentTypes,
requestedSourceMapPaths,
dispatch,
completeActionCreator: fetchGuestModelsComplete,
permissions
});
hostToHost$.next(sortItemOperationComplete(payload));
updatedModifiedItem(path);
enqueueSnackbar(formatMessage(guestMessages.sortOperationComplete));
},
error(error) {
console.error(`${type} failed`, error);
hostToHost$.next(sortItemOperationFailed());
// If write operation fails the items remains locked, so we need to dispatch unlockItem
dispatch(unlockItem({ path }));
enqueueSnackbar(formatMessage(guestMessages.sortOperationFailed), { variant: 'error' });
}
});
break;
}
case insertComponentOperation.type: {
const { fieldId, targetIndex, instance, shared = false, create = false } = payload;
let { modelId, parentModelId } = payload;
const model = models[parentModelId ?? modelId];
const path = models[modelId ?? parentModelId].craftercms.path;
const instanceContentType = contentTypes[instance.craftercms.contentTypeId];
const parentContentType = contentTypes[model.craftercms.contentTypeId];
if (isInheritedField(models[modelId], fieldId)) {
modelId = getModelIdFromInheritedField(models[modelId], fieldId, modelIdByPath);
parentModelId = findParentModelId(modelId, hierarchyMap, models);
}
const shouldSerializeFn = (instanceFieldId) =>
upToDateRefs.current.cdataEscapedFieldPatterns.some((pattern) => Boolean(instanceFieldId.match(pattern)));
// Cases:
// - Shared new - shared: true, create: true -> insertComponent
// - Shared existing - shared: true, create: false -> insertInstance
// - Embedded new - shared: false, create: true -> insertComponent
// * Embedded existing - shared: false, create: false -> insertInstance <- This case doesn't go through here, it goes by the move/sort operation.
let serviceObservable = create
? // region insertComponent
insertComponent(
siteId,
models[parentModelId ? parentModelId : modelId].craftercms.path,
modelId,
fieldId,
targetIndex,
parentContentType,
instance,
instanceContentType,
shared,
shouldSerializeFn
)
: // endregion
// region insertInstance
insertInstance(
siteId,
models[parentModelId ? parentModelId : modelId].craftercms.path,
modelId,
fieldId,
targetIndex,
parentContentType,
instance
);
// endregion
// Writing the xml document for the component being inserted only applies to new & shared.
if (shared && create) {
let postWriteObs = serviceObservable;
serviceObservable = writeInstance(siteId, instance, instanceContentType, shouldSerializeFn).pipe(
switchMap(() => postWriteObs)
);
}
serviceObservable.subscribe({
next() {
issueDescriptorRequest({
site: siteId,
path: path ?? models[parentModelId].craftercms.path,
contentTypes,
requestedSourceMapPaths,
dispatch,
completeActionCreator: fetchGuestModelsComplete,
permissions
});
hostToGuest$.next(
insertOperationComplete({
...payload,
currentFullUrl: `${guestBase}${upToDateRefs.current.currentUrlPath}`
})
);
updatedModifiedItem(path);
enqueueSnackbar(formatMessage(guestMessages.insertOperationComplete));
},
error(error) {
console.error(`${type} failed`, error);
hostToGuest$.next(insertOperationFailed());
// If write operation fails the items remains locked, so we need to dispatch unlockItem
dispatch(unlockItem({ path }));
enqueueSnackbar(formatMessage(guestMessages.insertOperationFailed), { variant: 'error' });
}
});
break;
}
case insertItemOperation.type: {
const { modelId, parentModelId, fieldId, index, instance } = payload;
const path = models[parentModelId ?? modelId].craftercms.path;
insertItem(siteId, modelId, fieldId, index, instance, path, (instanceFieldId) =>
upToDateRefs.current.cdataEscapedFieldPatterns.some((pattern) => Boolean(instanceFieldId.match(pattern)))
).subscribe({
next() {
hostToGuest$.next(insertItemOperationComplete());
enqueueSnackbar(formatMessage(guestMessages.insertItemOperationComplete));
},
error(error) {
console.error(`${type} failed`, error);
hostToGuest$.next(insertItemOperationFailed());
// If write operation fails the items remains locked, so we need to dispatch unlockItem
dispatch(unlockItem({ path }));
enqueueSnackbar(formatMessage(guestMessages.insertItemOperationFailed), { variant: 'error' });
}
});
break;
}
case duplicateItemOperation.type: {
const { modelId, parentModelId, fieldId, index } = payload;
const path = models[parentModelId ?? modelId].craftercms.path;
duplicateItem(siteId, modelId, fieldId, index, path).subscribe({
next({ newItem }) {
issueDescriptorRequest({
site: siteId,
path: newItem.path,
contentTypes,
requestedSourceMapPaths,
dispatch,
completeActionCreator: fetchPrimaryGuestModelComplete,
permissions
});
hostToGuest$.next(duplicateItemOperationComplete());
enqueueSnackbar(formatMessage(guestMessages.duplicateItemOperationComplete));
},
error(error) {
console.error(`${type} failed`, error);
hostToGuest$.next(duplicateItemOperationFailed());
// If write operation fails the items remains locked, so we need to dispatch unlockItem
dispatch(unlockItem({ path }));
enqueueSnackbar(formatMessage(guestMessages.duplicateItemOperationFailed), { variant: 'error' });
}
});
break;
}
case moveItemOperation.type: {
const { originalFieldId, originalIndex, targetFieldId, targetIndex } = payload;
let { originalModelId, originalParentModelId, targetModelId, targetParentModelId } = payload;
const originPath = models[originalParentModelId ? originalParentModelId : originalModelId].craftercms.path;
const targetPath = models[targetParentModelId ? targetParentModelId : targetModelId].craftercms.path;
if (isInheritedField(models[originalModelId], originalFieldId)) {
originalModelId = getModelIdFromInheritedField(models[originalModelId], originalFieldId, modelIdByPath);
originalParentModelId = findParentModelId(originalModelId, hierarchyMap, models);
}
if (isInheritedField(models[targetModelId], targetFieldId)) {
targetModelId = getModelIdFromInheritedField(models[targetModelId], targetFieldId, modelIdByPath);
targetParentModelId = findParentModelId(targetModelId, hierarchyMap, models);
}
moveItem(
siteId,
originalModelId,
originalFieldId,
originalIndex,
targetModelId,
targetFieldId,
targetIndex,
originPath,
targetPath
).subscribe({
next() {
hostToGuest$.next(moveItemOperationComplete());
dispatch(
batchActions([
reloadDetailedItem({
path: originPath
}),
reloadDetailedItem({
path: targetPath
})
])
);
enqueueSnackbar(formatMessage(guestMessages.moveOperationComplete));
},
error(error) {
console.error(`${type} failed`, error);
hostToGuest$.next(moveItemOperationFailed());
// If write operation fails the items remains locked, so we need to dispatch unlockItem
dispatch(batchActions([unlockItem({ path: originPath }), unlockItem({ path: targetPath })]));
enqueueSnackbar(formatMessage(guestMessages.moveOperationFailed), { variant: 'error' });
}
});
break;
}
case deleteItemOperation.type: {
const { fieldId, index } = payload;
let { modelId, parentModelId } = payload;
const path = models[modelId ?? parentModelId].craftercms.path;
({ modelId, parentModelId } = getInheritanceParentIdsForField(
fieldId,
models,
modelId,
parentModelId,
modelIdByPath,
hierarchyMap
));
deleteItem(
siteId,
modelId,
fieldId,
index,
models[parentModelId ? parentModelId : modelId].craftercms.path
).subscribe({
next: () => {
issueDescriptorRequest({
site: siteId,
path: path ?? models[parentModelId].craftercms.path,
contentTypes,
requestedSourceMapPaths,
dispatch,
completeActionCreator: fetchGuestModelsComplete,
permissions
});
hostToHost$.next(deleteItemOperationComplete(payload));
updatedModifiedItem(path);
enqueueSnackbar(formatMessage(guestMessages.deleteOperationComplete));
},
error: (error) => {
console.error(`${type} failed`, error);
hostToHost$.next(deleteItemOperationFailed());
// If write operation fails the items remains locked, so we need to dispatch unlockItem
dispatch(unlockItem({ path }));
enqueueSnackbar(formatMessage(guestMessages.deleteOperationFailed), { variant: 'error' });
}
});
break;
}
case updateFieldValueOperation.type: {
const { fieldId, index, value } = payload;
let { modelId, parentModelId } = payload;
let path = models[parentModelId ? parentModelId : modelId].craftercms.path;
if (isInheritedField(models[modelId], fieldId)) {
modelId = getModelIdFromInheritedField(models[modelId], fieldId, modelIdByPath);
path = models[modelId].craftercms.path;
}
updateField(
siteId,
modelId,
fieldId,
index,
path,
value,
upToDateRefs.current.cdataEscapedFieldPatterns.some((pattern) => Boolean(fieldId.match(pattern)))
)
.pipe(switchMap(() => fetchSandboxItemService(siteId, path)))
.subscribe({
next(item) {
hostToGuest$.next(updateFieldValueOperationComplete({ item }));
updatedModifiedItem(path);
enqueueSnackbar(formatMessage(guestMessages.updateOperationComplete));
},
error(error) {
console.error(`${type} failed`, error);
dispatch(unlockItem({ path }));
hostToGuest$.next(updateFieldValueOperationFailed());
enqueueSnackbar(formatMessage(guestMessages.updateOperationFailed), { variant: 'error' });
}
});
break;
}
case iceZoneSelected.type: {
dispatch(selectForEdit(payload));
break;
}
case clearSelectedZones.type: {
dispatch(clearSelectForEdit());
break;
}
case instanceDragBegun.type:
case instanceDragEnded.type: {
dispatch(setItemBeingDragged(type === instanceDragBegun.type ? payload : null));
break;
}
case contentTypeDropTargetsResponse.type: {
dispatch(setContentTypeDropTargets(payload));
break;
}
case snackGuestMessage.type: {
enqueueSnackbar(
payload.id in guestMessages ? formatMessage(guestMessages[payload.id], payload.values ?? {}) : payload.id,
{
variant: payload.level
? payload.level === 'required'
? 'error'
: payload.level === 'suggestion'
? 'warning'
: 'info'
: null
}
);
break;
}
case hotKey.type: {
upToDateRefs.current.onShortCutKeypress(payload);
break;
}
case showEditDialogAction.type: {
dispatch(
showEditDialog({
authoringBase,
path: upToDateRefs.current.guest.path,
selectedFields: payload.selectedFields,
site: siteId
})
);
break;
}
case updateRteConfig.type: {
// @ts-ignore - TODO: type action accordingly
getHostToGuestBus().next(updateRteConfig({ rteConfig: upToDateRefs.current.rteConfig ?? {} }));
break;
}
case requestEdit.type: {
let { store } = upToDateRefs.current;
const { modelId, parentModelId, fields, typeOfEdit: type, index } = payload;
const path = models[parentModelId ? parentModelId : modelId].craftercms.path;
let item = store.getState().content.itemsByPath[path];
const model = models[modelId];
const contentType = contentTypes[model.craftercms.contentTypeId];
if (type === 'content') {
// Not quite sure if it ever happens that the item isn't already loaded.
(item ? of(item) : fetchSandboxItemService(siteId, path, { castAsDetailedItem: true })).subscribe(
(item) => {
itemActionDispatcher({
item,
site: siteId,
option: 'edit',
dispatch,
authoringBase,
formatMessage,
extraPayload: {
modelId: parentModelId ? modelId : null,
selectedFields: fields,
index
}
});
}
);
} else if (type === 'template') {
dispatch(editContentTypeTemplate({ contentTypeId: contentType.id }));
} else {
dispatch(editControllerActionCreator(contentType.type, contentType.id));
}
break;
}
case requestWorkflowCancellationDialog.type: {
fetchWorkflowAffectedItems(payload.siteId, payload.path).subscribe((items) => {
dispatch(
showWorkflowCancellationDialog({
items,
onClosed: batchActions([
workflowCancellationDialogClosed(),
requestWorkflowCancellationDialogOnResult({ type: 'close' })
]),
onContinue: requestWorkflowCancellationDialogOnResult({ type: 'continue' })
})
);
});
break;
}
case showItemMegaMenu.type: {
const extendedAction = action;
const iframe = document.querySelector('#crafterCMSPreviewIframe');
const iframeRect = iframe.getBoundingClientRect();
const id = 'xbItemMegaMenuClosed';
extendedAction.payload.anchorPosition.top += iframeRect.top;
extendedAction.payload.anchorPosition.left += iframeRect.left;
extendedAction.payload.onClosed = batchActions([itemMegaMenuClosed(), dispatchDOMEvent({ id })]);
createCustomDocumentEventListener(id, () => iframe.contentWindow.focus());
dispatch(action);
break;
}
// region actions whitelisted
case unlockItem.type:
case errorPageCheckIn.type:
case allowedContentTypesUpdate.type: {
dispatch(action);
break;
}
// endregion
case showRtePickerActions.type: {
const typedPayload = payload;
const { setDataSourceActionsListState, showToolsPanel, toolsPanelWidth, browseFilesDialogState } =
upToDateRefs.current;
const onShowSingleFileUploadDialog = (path, type) => {
setDataSourceActionsListState(dataSourceActionsListInitialState);
if (path) {
dispatch(
showSingleFileUploadDialog({
site: siteId,
path,
fileTypes: type === 'image' ? ['image/*'] : type === 'video' ? ['video/*'] : ['audio/*'],
onClose: batchActions([
closeSingleFileUploadDialog(),
dispatchDOMEvent({ id: 'fileUploadCanceled' })
]),
onUploadComplete: batchActions([
closeSingleFileUploadDialog(),
dispatchDOMEvent({ id: 'fileUploaded' })
])
})
);
let unsubscribe, cancelUnsubscribe;
unsubscribe = createCustomDocumentEventListener('fileUploaded', ({ successful: response }) => {
const file = response[0];
const filePath = `${file.meta.path}${file.meta.path.endsWith('/') ? '' : '/'}${file.meta.name}`;
onRtePickerResult({ path: filePath, name: file.meta.name });
cancelUnsubscribe();
});
cancelUnsubscribe = createCustomDocumentEventListener('fileUploadCanceled', () => {
onRtePickerResult();
unsubscribe();
});
} else {
dispatch(
showSystemNotification({
message: formatMessage(guestMessages.noPathSetInDataSource)
})
);
}
};
const onShowBrowseFilesDialog = (path, type) => {
const mimeTypes =
type === 'image'
? ['image/png', 'image/jpeg', 'image/gif', 'image/jpg']
: type === 'video'
? ['video/mp4']
: ['audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav'];
setDataSourceActionsListState(dataSourceActionsListInitialState);
if (path) {
setBrowseFilesDialogPath(path);
setBrowseFilesDialogMimeTypes(mimeTypes);
browseFilesDialogState.onOpen();
} else {
dispatch(
showSystemNotification({
message: formatMessage(guestMessages.noPathSetInDataSource)
})
);
}
};
const dataSourcesByType = {
image: ['allowImageUpload', 'allowImagesFromRepo'],
media: ['allowVideoUpload', 'allowVideosFromRepo', 'allowAudioUpload', 'allowAudioFromRepo']
};
// Tinymce handles both audio and video as 'media' types. This lookup is used to determine which type of media to handle.
const mediaTypes = {
allowAudioUpload: 'audio',
allowAudioFromRepo: 'audio',
allowVideoUpload: 'video',
allowVideosFromRepo: 'video'
};
// filter data sources to only the ones that match the type
const dataSourcesKeys = Object.keys(typedPayload.datasources).filter((datasourceId) =>
dataSourcesByType[typedPayload.type]?.includes(datasourceId)
);
// directly open corresponding dialog
if (dataSourcesKeys.length === 1) {
// determine if upload or browse
const key = dataSourcesKeys[0];
const processedPath = processPathMacros({
path: typedPayload.datasources[key].value,
objectId: typedPayload.model.craftercms.id,
objectGroupId: typedPayload.model.objectGroupId
});
if (key === 'allowImageUpload' || key === 'allowVideoUpload' || 'allowAudioUpload') {
onShowSingleFileUploadDialog(processedPath, mediaTypes[key] ?? typedPayload.type);
} else {
onShowBrowseFilesDialog(processedPath, mediaTypes[key] ?? typedPayload.type);
}
} else if (dataSourcesKeys.length > 1) {
// create items for DataSourcesActionsList
const dataSourcesItems = [];
dataSourcesKeys.forEach((dataSourceKey) => {
dataSourcesItems.push({
label: formatMessage(guestMessages[dataSourceKey]),
path: processPathMacros({
path: typedPayload.datasources[dataSourceKey].value,
objectId: typedPayload.model.objectId,
objectGroupId: typedPayload.model.objectGroupId
}),
action:
dataSourceKey === 'allowImageUpload' ||
dataSourceKey === 'allowVideoUpload' ||
dataSourceKey === 'allowAudioUpload'
? onShowSingleFileUploadDialog
: onShowBrowseFilesDialog,
type: mediaTypes[dataSourceKey] ?? typedPayload.type
});
});
const { left, top, height } = typedPayload.rect;
setDataSourceActionsListState({
show: true,
items: dataSourcesItems,
rect: {
...typedPayload.rect,