UNPKG

@craftercms/studio-ui

Version:

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

1,242 lines (1,240 loc) 55.5 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, { 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,