@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
299 lines (297 loc) • 11.8 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 DialogHeader from '../DialogHeader/DialogHeader';
import DialogBody from '../DialogBody/DialogBody';
import { fetchContentXML, lock, writeContent } from '../../services/content';
import { ConditionalLoadingState } from '../LoadingState/LoadingState';
import AceEditor from '../AceEditor/AceEditor';
import useStyles from './styles';
import { useDispatch } from 'react-redux';
import { updateCodeEditorDialog } from '../../state/actions/dialogs';
import Skeleton from '@mui/material/Skeleton';
import ListSubheader from '@mui/material/ListSubheader';
import DialogFooter from '../DialogFooter/DialogFooter';
import SecondaryButton from '../SecondaryButton';
import { FormattedMessage, useIntl } from 'react-intl';
import { showErrorDialog } from '../../state/reducers/dialogs/error';
import { showSystemNotification } from '../../state/actions/system';
import translations from './translations';
import MenuItem from '@mui/material/MenuItem';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import ExpandMoreRoundedIcon from '@mui/icons-material/ExpandMoreRounded';
import { isItemLockedForMe, isLockedState } from '../../utils/content';
import { useContentTypes } from '../../hooks/useContentTypes';
import { useActiveUser } from '../../hooks/useActiveUser';
import { useActiveSiteId } from '../../hooks/useActiveSiteId';
import { useDetailedItem } from '../../hooks/useDetailedItem';
import { useReferences } from '../../hooks/useReferences';
import { getHostToGuestBus } from '../../utils/subjects';
import { reloadRequest } from '../../state/actions/preview';
import { getContentModelSnippets } from './utils';
import { batchActions } from '../../state/actions/misc';
import { MultiChoiceSaveButton } from '../MultiChoiceSaveButton';
import useUpToDateRefs from '../../hooks/useUpdateRefs';
import { useEnhancedDialogContext } from '../EnhancedDialog';
import { writeConfiguration } from '../../services/configuration';
export function CodeEditorDialogContainer(props) {
const { path, onMinimize, onClose, mode, readonly, contentType, onFullScreen, onSuccess } = props;
const { open, isSubmitting } = useEnhancedDialogContext();
const item = useDetailedItem(path);
const site = useActiveSiteId();
const user = useActiveUser();
const [loading, setLoading] = useState(false);
const [content, setContent] = useState(null);
const itemLoaded = Boolean(item); // isLocked and isLockedForMe only hold accurate value if item was already loaded.
const isLocked = isLockedState(item?.state);
const isLockedForMe = isItemLockedForMe(item, user.username);
const shouldPerformLock = open && itemLoaded && !readonly && !isLockedForMe && !isLocked;
const { classes } = useStyles();
const editorRef = useRef();
const dispatch = useDispatch();
const { formatMessage } = useIntl();
const contentTypes = useContentTypes();
const [anchorEl, setAnchorEl] = React.useState(null);
const [snippets, setSnippets] = useState({});
const [contentModelSnippets, setContentModelSnippets] = useState(null);
const storedId = 'codeEditor';
const {
'craftercms.freemarkerCodeSnippets': freemarkerCodeSnippets,
'craftercms.groovyCodeSnippets': groovyCodeSnippets
} = useReferences();
const onChangeTimeoutRef = useRef(null);
const onEditorChanges = () => {
clearTimeout(onChangeTimeoutRef.current);
onChangeTimeoutRef.current = setTimeout(() => {
dispatch(
updateCodeEditorDialog({
hasPendingChanges: content !== editorRef.current.getValue()
})
);
}, 150);
};
const save = (callback) => {
if (!isLockedForMe && !readonly) {
dispatch(updateCodeEditorDialog({ isSubmitting: true }));
const value = editorRef.current.getValue();
const isConfig = path.startsWith('/config');
const module = isConfig ? path.split('/')[2] : null;
const service$ = isConfig
? writeConfiguration(site, path.replace(`/config/${module}`, ''), module, value)
: writeContent(site, path, value, { unlock: false });
service$.subscribe({
next() {
dispatch(
batchActions([
showSystemNotification({ message: formatMessage(translations.saved) }),
updateCodeEditorDialog({ isSubmitting: false, hasPendingChanges: false })
])
);
setTimeout(callback);
getHostToGuestBus().next(reloadRequest());
onSuccess?.();
},
error({ response }) {
dispatch(showErrorDialog({ error: response }));
}
});
}
};
const onSave = () => save(() => setContent(editorRef.current.getValue()));
const onAddSnippet = (event) => {
setAnchorEl(event.currentTarget);
};
const closeSnippets = () => {
setAnchorEl(null);
};
const onSnippetSelected = (snippet) => {
const cursorPosition = editorRef.current.getCursorPosition();
editorRef.current.session.insert(cursorPosition, snippet.value);
editorRef.current.focus();
closeSnippets();
};
const onCloseButtonClick = (e) => {
fnRefs.current.onClose(e, null);
};
const onMultiChoiceSaveButtonClick = (e, type) => {
switch (type) {
case 'save':
onSave();
break;
case 'saveAndClose':
save(() => onCloseButtonClick(null));
break;
case 'saveAndMinimize':
save(() => {
setContent(editorRef.current.getValue());
onMinimize?.();
});
break;
}
};
const onAceInit = (editor) => {
editor.commands.addCommand({
name: 'saveToCrafter',
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
exec: () => fnRefs.current.onSave(),
readOnly: false
});
};
const fnRefs = useUpToDateRefs({ onSave, onClose });
// add content model variables
useEffect(() => {
if (contentTypes && item) {
const _contentType = contentType
? contentType
: Object.values(contentTypes).find((contentType) => contentType.displayTemplate === item.path)?.id;
if (mode === 'ftl') {
let { contentVariable, ...rest } = freemarkerCodeSnippets;
setSnippets(rest);
if (contentVariable && _contentType) {
setContentModelSnippets(getContentModelSnippets(contentVariable, contentTypes[_contentType].fields));
}
} else if (mode === 'groovy') {
let { accessContentModel, ...rest } = groovyCodeSnippets;
setSnippets(rest);
if (accessContentModel && _contentType) {
setContentModelSnippets(getContentModelSnippets(accessContentModel, contentTypes[_contentType].fields));
}
}
}
}, [contentTypes, contentType, mode, item, freemarkerCodeSnippets, groovyCodeSnippets]);
useEffect(() => {
if (content === null) {
setLoading(true);
dispatch(updateCodeEditorDialog({ isSubmitting: true }));
const subscription = fetchContentXML(site, path).subscribe((xml) => {
setContent(xml);
setLoading(false);
dispatch(updateCodeEditorDialog({ isSubmitting: false }));
});
return () => {
subscription.unsubscribe();
};
}
}, [content, dispatch, path, site]);
useEffect(() => {
if (shouldPerformLock) {
lock(site, path).subscribe();
}
}, [path, shouldPerformLock, site]);
return React.createElement(
React.Fragment,
null,
React.createElement(DialogHeader, {
title: item ? item.label : React.createElement(Skeleton, { width: '120px' }),
onCloseButtonClick: onCloseButtonClick,
onMinimizeButtonClick: onMinimize,
onFullScreenButtonClick: onFullScreen,
disabled: isSubmitting
}),
React.createElement(
DialogBody,
{
className: classes.dialogBody,
sx: {
'.MuiDialogTitle-root + &': {
pt: 0
}
}
},
React.createElement(
ConditionalLoadingState,
{ isLoading: loading, classes: { root: classes.loadingState } },
React.createElement(AceEditor, {
ref: editorRef,
autoFocus: !readonly,
mode: `ace/mode/${mode}`,
value: content ?? '',
onChange: onEditorChanges,
readOnly: isLockedForMe || readonly,
classes: { editorRoot: classes.aceRoot },
enableBasicAutocompletion: true,
enableSnippets: true,
enableLiveAutocompletion: true,
onInit: onAceInit
})
)
),
!readonly &&
React.createElement(
DialogFooter,
null,
React.createElement(
Button,
{
onClick: onAddSnippet,
endIcon: React.createElement(ExpandMoreRoundedIcon, null),
className: classes.addSnippet,
disabled: isSubmitting || isLockedForMe
},
React.createElement(FormattedMessage, { id: 'codeEditor.insertCode', defaultMessage: 'Insert Code' })
),
React.createElement(
SecondaryButton,
{ onClick: onCloseButtonClick, sx: { mr: '8px' }, disabled: isSubmitting },
React.createElement(FormattedMessage, { id: 'words.cancel', defaultMessage: 'Cancel' })
),
React.createElement(MultiChoiceSaveButton, {
loading: isSubmitting,
disabled: isLockedForMe,
storageKey: storedId,
onClick: onMultiChoiceSaveButtonClick
})
),
React.createElement(
Menu,
{ anchorEl: anchorEl, keepMounted: true, open: Boolean(anchorEl), onClose: closeSnippets },
contentModelSnippets &&
React.createElement(
ListSubheader,
{ disableSticky: true },
React.createElement(FormattedMessage, { id: 'codeEditor.contentModel', defaultMessage: 'Content model' })
),
contentModelSnippets?.map((snippet, i) =>
React.createElement(MenuItem, { key: i, onClick: () => onSnippetSelected(snippet), dense: true }, snippet.label)
),
React.createElement(
ListSubheader,
null,
React.createElement(FormattedMessage, { id: 'words.snippets', defaultMessage: 'Snippets' })
),
Object.values(snippets).map((snippet, i) =>
React.createElement(MenuItem, { key: i, onClick: () => onSnippetSelected(snippet), dense: true }, snippet.label)
)
)
);
}
export default CodeEditorDialogContainer;