@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
791 lines (789 loc) • 26 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 { createReducer } from '@reduxjs/toolkit';
import {
allowedContentTypesUpdate,
clearDropTargets,
clearSelectForEdit,
closeToolsPanel,
contentTypeDropTargetsResponse,
errorPageCheckIn,
fetchAssetsPanelItems,
fetchAssetsPanelItemsComplete,
fetchAssetsPanelItemsFailed,
fetchAudiencesPanelModel,
fetchAudiencesPanelModelComplete,
fetchAudiencesPanelModelFailed,
fetchComponentsByContentType,
fetchComponentsByContentTypeComplete,
fetchComponentsByContentTypeFailed,
fetchContentModelComplete,
fetchContentTypesComplete,
fetchGuestModelsComplete,
fetchPrimaryGuestModelComplete,
guestCheckIn,
guestCheckOut,
guestModelUpdated,
guestPathUpdated,
initIcePanelConfig,
initPreviewConfig,
initRichTextEditorConfig,
initToolbarConfig,
initToolsPanelConfig,
mainModelModifiedExternally,
openToolsPanel,
popIcePanelPage,
popToolsPanelPage,
pushIcePanelPage,
pushToolsPanelPage,
selectForEdit,
setActiveTargetingModel,
setActiveTargetingModelComplete,
setActiveTargetingModelFailed,
setContentTypeFilter,
setEditModePadding,
setHighlightMode,
setHostHeight,
setHostSize,
setHostWidth,
setItemBeingDragged,
setPreviewEditMode,
setWindowSize,
toggleEditModePadding,
updateAudiencesPanelModel,
updateIcePanelWidth,
updateToolsPanelWidth
} from '../actions/preview';
import {
applyDeserializedXMLTransforms,
createEntityState,
createLookupTable,
nnou,
nou,
reversePluckProps
} from '../../utils/object';
import { changeSiteComplete } from '../actions/sites';
import { deserialize, fromString } from '../../utils/xml';
import { defineMessages } from 'react-intl';
import { fetchSiteUiConfigComplete } from '../actions/configuration';
const messages = defineMessages({
emptyUiConfigMessageTitle: {
id: 'emptyUiConfigMessageTitle.title',
defaultMessage: 'Configuration is empty'
},
emptyUiConfigMessageSubtitle: {
id: 'emptyUiConfigMessageTitle.subtitle',
defaultMessage: 'Nothing is set to be shown here.'
},
noUiConfigMessageTitle: {
id: 'noUiConfigMessageTitle.title',
defaultMessage: 'Configuration file missing'
},
noUiConfigMessageSubtitle: {
id: 'noUiConfigMessageTitle.subtitle',
defaultMessage: 'Add & configure `ui.xml` on your project to show content here.'
}
});
const audiencesPanelInitialState = {
isFetching: null,
isApplying: false,
error: null,
model: null,
applied: false
};
const assetsPanelInitialState = createEntityState({
page: [],
query: {
keywords: '',
offset: 0,
limit: 10,
filters: {
'mime-type': ['image/png', 'image/jpeg', 'image/gif', 'video/mp4', 'image/svg+xml']
}
}
});
const componentsInitialState = createEntityState({
page: [],
query: {
keywords: '',
offset: 0,
limit: 10
},
contentTypeFilter: 'compatible',
inPageInstances: {}
});
const initialState = {
editMode: false,
highlightMode: 'all',
hostSize: { width: null, height: null },
toolsPanelPageStack: [],
showToolsPanel: import.meta.env.VITE_SHOW_TOOLS_PANEL ? import.meta.env.VITE_SHOW_TOOLS_PANEL === 'true' : true,
toolsPanelWidth: 240,
icePanelWidth: 240,
icePanelStack: [],
guest: null,
assets: assetsPanelInitialState,
audiencesPanel: audiencesPanelInitialState,
components: componentsInitialState,
dropTargets: {
selectedContentType: null,
byId: null
},
toolsPanel: null,
toolbar: {
leftSection: null,
middleSection: null,
rightSection: null
},
icePanel: null,
richTextEditor: null,
editModePadding: false,
windowSize: window.innerWidth,
xbDetectionTimeoutMs: 5000,
error: null
};
const minDrawerWidth = 240;
const minPreviewWidth = 320;
const isDrawerWidthValid = (windowSize, drawerWidth, oppositeDrawerWidth, showOppositeDrawer) => {
const maxWidth = windowSize - (showOppositeDrawer ? oppositeDrawerWidth : 0) - minPreviewWidth;
return drawerWidth < minDrawerWidth || drawerWidth > maxWidth;
};
const previewWidthResult = (windowSize, showCurrentPanel, showOppositePanel, currentPanelWidth, oppositePanelWidth) => {
return windowSize - (showCurrentPanel ? currentPanelWidth : 0) - (showOppositePanel ? oppositePanelWidth : 0);
};
const onOpenDrawerAdjustWidths = (
windowSize,
showCurrentPanel,
showOppositePanel,
currentPanelWidth,
oppositePanelWidth
) => {
let adjustedCurrentPanelWidth = currentPanelWidth;
let adjustedOppositePanelWidth = oppositePanelWidth;
const result = previewWidthResult(
windowSize,
showCurrentPanel,
showOppositePanel,
currentPanelWidth,
oppositePanelWidth
);
if (result < minPreviewWidth) {
adjustedCurrentPanelWidth = minDrawerWidth;
adjustedOppositePanelWidth = windowSize - minPreviewWidth - minDrawerWidth;
}
return {
currentPanel: adjustedCurrentPanelWidth < minDrawerWidth ? minDrawerWidth : adjustedCurrentPanelWidth,
oppositePanel: adjustedOppositePanelWidth < minDrawerWidth ? minDrawerWidth : adjustedOppositePanelWidth
};
};
const fetchGuestModelsCompleteHandler = (state, { type, payload }) => {
if (nnou(state.guest)) {
return {
...state,
guest: {
...state.guest,
modelId: type === fetchPrimaryGuestModelComplete.type ? payload.model.craftercms.id : state.guest.modelId,
models: {
...state.guest.models,
...payload.modelLookup
},
modelIdByPath: {
...state.guest.modelIdByPath,
...payload.modelIdByPath
},
hierarchyMap: {
...state.guest?.hierarchyMap,
...payload.hierarchyMap
}
}
};
} else {
// TODO: Currently getting models before check in some cases when coming from a different site.
console.error('[reducer/preview] Guest models received before guest check in.');
return state;
}
};
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(initPreviewConfig, (state, { payload }) => {
const configDOM = fromString(payload.configXml);
const previewConfigEl = configDOM.querySelector('[id="craftercms.components.Preview"]');
const initialEditModeOn = previewConfigEl?.getAttribute('initialEditModeOn');
const initialHighlightMode = previewConfigEl?.getAttribute('initialHighlightMode');
// If there is no storedEditMode, set it to the value of initialEditModeOn (config value), otherwise, defaults to true
state.editMode = payload.storedEditMode ?? (initialEditModeOn ? initialEditModeOn === 'true' : true);
state.highlightMode =
payload.storedHighlightMode ??
(['all', 'move'].includes(initialHighlightMode) ? initialHighlightMode : state.highlightMode);
state.editModePadding = payload.storedPaddingMode ?? state.editModePadding;
})
.addCase(openToolsPanel, (state) => {
const { windowSize, editMode, toolsPanelWidth, icePanelWidth } = state;
const adjustedWidths = onOpenDrawerAdjustWidths(windowSize, true, editMode, toolsPanelWidth, icePanelWidth);
state.showToolsPanel = true;
state.toolsPanelWidth = adjustedWidths.currentPanel;
state.icePanelWidth = adjustedWidths.oppositePanel;
})
.addCase(closeToolsPanel, (state) => {
state.showToolsPanel = false;
})
.addCase(setHostSize, (state, { payload }) => {
if (isNaN(payload.width)) {
payload.width = state.hostSize.width;
}
if (isNaN(payload.height)) {
payload.height = state.hostSize.height;
}
state.hostSize = {
...state.hostSize,
width: minFrameSize(payload.width),
height: minFrameSize(payload.height)
};
})
.addCase(setHostWidth, (state, { payload }) => {
if (isNaN(payload)) {
return state;
}
state.hostSize = {
...state.hostSize,
width: minFrameSize(payload)
};
})
.addCase(setHostHeight, (state, { payload }) => {
if (isNaN(payload)) {
return state;
}
state.hostSize = {
...state.hostSize,
height: minFrameSize(payload)
};
})
.addCase(fetchContentModelComplete, (state, { payload }) => {
return {
...state,
currentModels: payload
};
})
.addCase(guestCheckIn, (state, { payload }) => {
const { location, path } = payload;
const href = location.href;
const origin = location.origin;
const url = href.replace(location.origin, '');
state.error = null;
state.guest = {
allowedContentTypes: null,
url,
origin,
modelId: null,
path,
models: null,
hierarchyMap: null,
modelIdByPath: null,
selected: null,
itemBeingDragged: null,
mainModelModifier: null,
contentTypesUpdated: false
};
})
.addCase(guestCheckOut, (state) => {
let nextState = state;
if (state.guest) {
nextState = {
...nextState,
guest: null
};
}
// If guest checks out, doesn't mean site is changing necessarily
// hence content types haven't changed
// if (state.contentTypes) {
// nextState = { ...nextState, contentTypes: null };
// }
return nextState;
})
.addCase(errorPageCheckIn, (state, { payload }) => {
state.error = {
...payload
};
})
.addCase(fetchPrimaryGuestModelComplete, fetchGuestModelsCompleteHandler)
.addCase(fetchGuestModelsComplete, fetchGuestModelsCompleteHandler)
.addCase(guestModelUpdated, (state, { payload: { model } }) => ({
...state,
guest: {
...state.guest,
models: {
...state.guest.models,
[model.craftercms.id]: model
}
}
}))
.addCase(selectForEdit, (state, { payload }) => {
if (state.guest === null) {
return state;
}
return {
...state,
guest: {
...state.guest,
selected: [payload]
}
};
})
.addCase(clearSelectForEdit, (state, { payload }) => {
if (state.guest === null) {
return state;
}
return {
...state,
guest: {
...state.guest,
selected: null
}
};
})
.addCase(setItemBeingDragged, (state, { payload }) => {
if (nou(state.guest)) {
return state;
}
return {
...state,
guest: {
...state.guest,
itemBeingDragged: payload
}
};
})
.addCase(fetchAudiencesPanelModel, (state) => ({
...state,
audiencesPanel: {
...state.audiencesPanel,
isFetching: true,
error: null
}
}))
.addCase(fetchAudiencesPanelModelComplete, (state, { payload }) => {
return {
...state,
audiencesPanel: {
...state.audiencesPanel,
isFetching: false,
error: null,
model: payload
}
};
})
.addCase(fetchAudiencesPanelModelFailed, (state, { payload }) => ({
...state,
audiencesPanel: { ...state.audiencesPanel, error: payload.response, isFetching: false }
}))
.addCase(updateAudiencesPanelModel, (state, { payload }) => ({
...state,
audiencesPanel: {
...state.audiencesPanel,
applied: false,
model: {
...state.audiencesPanel.model,
...payload
}
}
}))
.addCase(setActiveTargetingModel, (state, { payload }) => ({
...state,
audiencesPanel: {
...state.audiencesPanel,
isApplying: true
}
}))
.addCase(setActiveTargetingModelComplete, (state, { payload }) => ({
...state,
audiencesPanel: {
...state.audiencesPanel,
isApplying: false,
applied: true
}
}))
.addCase(setActiveTargetingModelFailed, (state, { payload }) => ({
...state,
audiencesPanel: {
...state.audiencesPanel,
isApplying: false,
applied: false,
error: payload.response
}
}))
.addCase(fetchAssetsPanelItems, (state, { payload: query }) => {
let newQuery = { ...state.assets.query, ...query };
return {
...state,
assets: {
...state.assets,
isFetching: true,
query: newQuery,
pageNumber: Math.ceil(newQuery.offset / newQuery.limit)
}
};
})
.addCase(fetchAssetsPanelItemsComplete, (state, { payload: searchResult }) => {
let itemsLookupTable = createLookupTable(searchResult.items, 'path');
let page = [...state.assets.page];
page[state.assets.pageNumber] = searchResult.items.map((item) => item.path);
return {
...state,
assets: {
...state.assets,
byId: { ...state.assets.byId, ...itemsLookupTable },
page,
count: searchResult.total,
isFetching: false,
error: null
}
};
})
.addCase(fetchAssetsPanelItemsFailed, (state, { payload }) => ({
...state,
assets: { ...state.assets, error: payload.response, isFetching: false }
}))
.addCase(fetchComponentsByContentType, (state, { payload }) => {
return {
...state,
components: {
...state.components,
isFetching: true,
query: { ...state.components.query, ...payload },
pageNumber: Math.ceil((payload.offset ?? state.components.query.offset) / state.components.query.limit)
}
};
})
.addCase(fetchComponentsByContentTypeComplete, (state, { payload }) => {
let page = [...state.components.page];
page[state.components.pageNumber] = Object.keys(payload.lookup);
return {
...state,
components: {
...state.components,
byId: { ...state.components.byId, ...payload.lookup },
page,
count: payload.count,
isFetching: false,
error: null
}
};
})
.addCase(fetchComponentsByContentTypeFailed, (state, { payload }) => ({
...state,
components: { ...state.components, error: payload.response, isFetching: false }
}))
.addCase(contentTypeDropTargetsResponse, (state, { payload }) => ({
...state,
dropTargets: {
...state.dropTargets,
selectedContentType: payload.contentTypeId,
byId: { ...state.dropTargets.byId, ...createLookupTable(payload.dropTargets) }
}
}))
.addCase(clearDropTargets, (state) => ({
...state,
dropTargets: {
...state.dropTargets,
selectedContentType: null,
byId: null
}
}))
.addCase(setContentTypeFilter, (state, { payload }) => ({
...state,
components: {
...state.components,
isFetching: null,
contentTypeFilter: payload,
pageNumber: 0,
query: {
...state.components.query,
offset: 0
}
}
}))
.addCase(setPreviewEditMode, (state, { payload }) => {
const { windowSize, showToolsPanel, toolsPanelWidth, icePanelWidth } = state;
const adjustedWidths = onOpenDrawerAdjustWidths(
windowSize,
payload.editMode,
showToolsPanel,
icePanelWidth,
toolsPanelWidth
);
state.editMode = payload.editMode;
state.highlightMode = payload.highlightMode ?? state.highlightMode;
if (payload.editMode) {
state.icePanelWidth = adjustedWidths.currentPanel;
state.toolsPanelWidth = adjustedWidths.oppositePanel;
}
})
.addCase(updateToolsPanelWidth, (state, { payload }) => {
const { windowSize, editMode, icePanelWidth } = state;
// when resizing tools panel, leave at least 320px for preview.
if (isDrawerWidthValid(windowSize, payload.width, icePanelWidth, editMode)) {
return state;
}
state.toolsPanelWidth = payload.width;
})
.addCase(updateIcePanelWidth, (state, { payload }) => {
const { windowSize, showToolsPanel, toolsPanelWidth } = state;
// When resizing ice panel, leave at least 320px for preview.
if (isDrawerWidthValid(windowSize, payload.width, toolsPanelWidth, showToolsPanel)) {
return state;
}
state.icePanelWidth = payload.width;
})
.addCase(pushToolsPanelPage, (state, { payload }) => {
return {
...state,
toolsPanelPageStack: [...state.toolsPanelPageStack, payload]
};
})
.addCase(popToolsPanelPage, (state) => {
let stack = [...state.toolsPanelPageStack];
stack.pop();
return {
...state,
toolsPanelPageStack: stack
};
})
.addCase(pushIcePanelPage, (state, { payload }) => {
return {
...state,
icePanelStack: [...state.icePanelStack, payload]
};
})
.addCase(popIcePanelPage, (state) => {
let stack = [...state.icePanelStack];
stack.pop();
return {
...state,
icePanelStack: stack
};
})
.addCase(guestPathUpdated, (state, { payload }) => ({
...state,
guest: {
...state.guest,
path: payload.path
}
}))
.addCase(setHighlightMode, (state, { payload }) => ({
...state,
highlightMode: payload.highlightMode
}))
.addCase(changeSiteComplete, (state) => {
return {
...state,
...reversePluckProps(
initialState,
'editMode',
'highlightMode',
'showToolsPanel',
'toolsPanelWidth',
'icePanelWidth'
)
};
})
.addCase(initToolsPanelConfig, (state, { payload }) => {
let toolsPanelConfig = { widgets: [] };
const arrays = ['widgets', 'permittedRoles', 'excludes'];
const lookupTables = ['fields'];
const configDOM = fromString(payload.configXml);
const toolsPanelPages = configDOM.querySelector(
'[id="craftercms.components.ToolsPanel"] > configuration > widgets'
);
if (toolsPanelPages) {
toolsPanelConfig = applyDeserializedXMLTransforms(deserialize(toolsPanelPages), {
arrays,
lookupTables
});
}
return {
...state,
...(payload.storedPage && { toolsPanelPageStack: [payload.storedPage] }),
toolsPanel: toolsPanelConfig,
toolsPanelWidth: payload.toolsPanelWidth ?? state.toolsPanelWidth
};
})
// After re-fetching site ui config (e.g. when config is modified), we need the tools to be
// re-initialized with the latest config. The components checks for whether their property is null before
// initializing so props must be nulled when config gets re-fetched in order for the components to re-initialize.
.addCase(fetchSiteUiConfigComplete, (state) => ({
...state,
toolsPanel: initialState.toolsPanel,
toolbar: initialState.toolbar,
icePanel: initialState.icePanel,
richTextEditor: initialState.richTextEditor
}))
.addCase(initToolbarConfig, (state, { payload }) => {
let toolbarConfig = {
leftSection: { widgets: [] },
middleSection: { widgets: [] },
rightSection: { widgets: [] }
};
const arrays = ['widgets', 'permittedRoles'];
const configDOM = fromString(payload.configXml);
const toolbar = configDOM.querySelector('[id="craftercms.components.PreviewToolbar"] > configuration');
if (toolbar) {
const leftSection = toolbar.querySelector('leftSection > widgets');
if (leftSection) {
toolbarConfig.leftSection = applyDeserializedXMLTransforms(deserialize(leftSection), {
arrays
});
}
const middleSection = toolbar.querySelector('middleSection > widgets');
if (middleSection) {
toolbarConfig.middleSection = applyDeserializedXMLTransforms(deserialize(middleSection), {
arrays
});
}
const rightSection = toolbar.querySelector('rightSection > widgets');
if (rightSection) {
toolbarConfig.rightSection = applyDeserializedXMLTransforms(deserialize(rightSection), {
arrays
});
}
}
return {
...state,
toolbar: toolbarConfig
};
})
.addCase(initIcePanelConfig, (state, { payload }) => {
let icePanelConfig = {
widgets: [
{
id: 'craftercms.components.EmptyState',
uiKey: -1,
configuration: {
title: messages.noUiConfigMessageTitle,
subtitle: messages.noUiConfigMessageSubtitle
}
}
]
};
const arrays = ['widgets', 'devices', 'values'];
const configDOM = fromString(payload.configXml);
const icePanel = configDOM.querySelector('[id="craftercms.components.ICEToolsPanel"] > configuration > widgets');
if (icePanel) {
const lookupTables = ['fields'];
icePanel.querySelectorAll('widget').forEach((e) => {
if (e.getAttribute('id') === 'craftercms.components.ToolsPanelPageButton') {
let target = 'icePanel';
e.querySelector(':scope > configuration')?.setAttribute('target', target);
}
});
icePanelConfig = applyDeserializedXMLTransforms(deserialize(icePanel), {
arrays,
lookupTables
});
}
return {
...state,
...(payload.storedPage && { icePanelStack: [payload.storedPage] }),
icePanel: icePanelConfig,
icePanelWidth: payload.icePanelWidth ?? state.icePanelWidth
};
})
.addCase(initRichTextEditorConfig, (state, { payload }) => {
let rteConfig = {};
const arrays = ['setups'];
const renameTable = { '#text': 'data' };
const configDOM = fromString(payload.configXml);
const rte = configDOM.querySelector('[id="craftercms.components.TinyMCE"] > configuration');
if (rte) {
try {
const conf = applyDeserializedXMLTransforms(deserialize(rte), {
arrays,
renameTable
}).configuration;
let setups = {};
conf.setups.forEach((setup) => {
setup.tinymceOptions = JSON.parse(setup.tinymceOptions.replaceAll('{site}', payload.siteId));
setups[setup.id] = setup;
});
rteConfig = setups;
} catch (e) {
console.error(e);
}
}
return {
...state,
richTextEditor: rteConfig
};
})
.addCase(setEditModePadding, (state, { payload }) => ({
...state,
editModePadding: payload.editModePadding
}))
.addCase(toggleEditModePadding, (state) => ({
...state,
editModePadding: !state.editModePadding
}))
.addCase(setWindowSize, (state, { payload }) => {
const windowSize = payload.size;
const { editMode, icePanelWidth, showToolsPanel, toolsPanelWidth } = state;
const result = previewWidthResult(windowSize, showToolsPanel, editMode, toolsPanelWidth, icePanelWidth);
let adjustedToolsPanelWidth = toolsPanelWidth < minDrawerWidth ? minDrawerWidth : toolsPanelWidth;
let adjustedIcePanelWidth = icePanelWidth < minDrawerWidth ? minDrawerWidth : icePanelWidth;
// if window size is less than minimum (320), or if both panels are bigger than window size, update tools panel and
// ice panel accordingly.
if (result < 0) {
adjustedToolsPanelWidth = minDrawerWidth;
adjustedIcePanelWidth = minDrawerWidth;
} else if (result < minPreviewWidth) {
adjustedToolsPanelWidth =
toolsPanelWidth - result / 2 < minDrawerWidth ? minDrawerWidth : toolsPanelWidth - result / 2;
adjustedIcePanelWidth =
icePanelWidth - result / 2 < minDrawerWidth ? minDrawerWidth : icePanelWidth - result / 2;
}
state.windowSize = windowSize;
state.toolsPanelWidth = adjustedToolsPanelWidth;
state.icePanelWidth = adjustedIcePanelWidth;
})
.addCase(mainModelModifiedExternally, (state, { payload }) => {
if (state.guest) state.guest.mainModelModifier = payload.user;
})
.addCase(allowedContentTypesUpdate, (state, { payload }) => {
if (!state.guest) return state;
state.guest.allowedContentTypes = payload;
})
.addCase(fetchContentTypesComplete, (state) => {
if (!state.guest) return state;
state.guest.contentTypesUpdated = true;
});
});
function minFrameSize(suggestedSize) {
return suggestedSize === null ? null : suggestedSize < 320 ? 320 : suggestedSize;
}
export default reducer;