UNPKG

@craftercms/studio-ui

Version:

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

630 lines (628 loc) 24.7 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 from 'react'; import { useEffect, useId, useState } from 'react'; import Paper from '@mui/material/Paper'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import DialogHeader from '../DialogHeader/DialogHeader'; import DialogFooter from '../DialogFooter/DialogFooter'; import { makeStyles } from 'tss-react/mui'; import palette from '../../styles/palette'; import FormControlLabel from '@mui/material/FormControlLabel'; import Radio from '@mui/material/Radio'; import RadioGroup from '@mui/material/RadioGroup'; import Collapse from '@mui/material/Collapse'; import ListItemText from '@mui/material/ListItemText'; import PublishOnDemandForm from '../PublishOnDemandForm'; import { nnou, nou } from '../../utils/object'; import Typography from '@mui/material/Typography'; import { bulkGoLive, fetchPublishingTargets, publishAll, publishByCommits } from '../../services/publishing'; import { showSystemNotification } from '../../state/actions/system'; import { useDispatch } from 'react-redux'; import { closeConfirmDialog, closePublishDialog, showConfirmDialog, showPublishDialog } from '../../state/actions/dialogs'; import { batchActions, dispatchDOMEvent } from '../../state/actions/misc'; import Link from '@mui/material/Link'; import { useSpreadState } from '../../hooks/useSpreadState'; import { useSelection } from '../../hooks/useSelection'; import { isBlank } from '../../utils/string'; import PrimaryButton from '../PrimaryButton'; import SecondaryButton from '../SecondaryButton'; import { createCustomDocumentEventListener } from '../../utils/dom'; import useUpdateRefs from '../../hooks/useUpdateRefs'; import { hasInitialPublish as hasInitialPublishService } from '../../services/sites'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import Box from '@mui/material/Box'; import useDetailedItem from '../../hooks/useDetailedItem'; import { showErrorDialog } from '../../state/reducers/dialogs/error'; import usePermissionsBySite from '../../hooks/usePermissionsBySite'; import Checkbox from '@mui/material/Checkbox'; import Alert, { alertClasses } from '@mui/material/Alert'; const useStyles = makeStyles()((theme) => ({ content: { backgroundColor: theme.palette.background.default, padding: '16px' }, modeSelector: { padding: '10px 25px', border: `1px solid ${palette.gray.light7}`, borderRadius: '10px' }, byPathModeSelector: { marginBottom: '10px' }, formContainer: { marginTop: '20px' }, noteContainer: { textAlign: 'center', marginTop: '20px' }, note: { color: theme.palette.action.active, display: 'inline-block', maxWidth: '700px' }, noteLink: { color: 'inherit', textDecoration: 'underline' }, initialPublishContainer: { display: 'flex', alignItems: 'center', flexDirection: 'column', rowGap: theme.spacing(2), padding: theme.spacing(5) }, initialPublishDescription: { maxWidth: '470px', textAlign: 'center' }, initialPublishIcon: { color: theme.palette.text.secondary, fontSize: '1.75rem' } })); const messages = defineMessages({ publishStudioWarning: { id: 'publishingDashboard.warning', defaultMessage: "This will force publish all items that match the pattern requested including their dependencies, and it may take a long time depending on the number of items. Please make sure that all modified items (including potentially someone's work in progress) are ready to be published before continuing." }, warningLabel: { id: 'words.warning', defaultMessage: 'Warning' }, publishStudioNote: { id: 'publishingDashboard.studioNote', defaultMessage: 'Publishing by path should be used to publish changes made in Studio via the UI. For changes made via direct git actions, please <a>publish by commit or tag</a>.' }, publishSuccess: { id: 'publishingDashboard.publishSuccess', defaultMessage: 'Published successfully.' }, bulkPublishStarted: { id: 'publishingDashboard.bulkPublishStarted', defaultMessage: 'Bulk Publish process has been started.' }, invalidForm: { id: 'publishingDashboard.invalidForm', defaultMessage: 'You cannot publish until form requirements are satisfied.' } }); const initialPublishStudioFormData = { path: '', publishingTarget: '', comment: '' }; const initialPublishGitFormData = { commitIds: '', publishingTarget: '', comment: '' }; const initialPublishEverythingFormData = { publishingTarget: '', comment: '' }; const pickMode = (mode) => { if (!mode) { return null; } else if (Array.isArray(mode)) { // If only one mode in the array, pre-select that. If more than one, don't pre-select anything. return mode.length === 1 ? mode[0] : null; } else { return mode; } }; export function PublishOnDemandWidget(props) { const { siteId, onSubmittingAndOrPendingChange, mode, showHeader = true, onCancel: onCancelProp, onSuccess: onSuccessProp } = props; const { classes } = useStyles(); const dispatch = useDispatch(); const { formatMessage } = useIntl(); const [selectedMode, setSelectedMode] = useState(() => pickMode(mode)); const [isSubmitting, setIsSubmitting] = useState(false); const permissionsBySite = usePermissionsBySite(); const hasPublishPermission = permissionsBySite[siteId]?.includes('publish'); const [hasInitialPublish, setHasInitialPublish] = useState(false); const initialPublishItem = useDetailedItem('/site/website/index.xml'); const [initialPublishingTarget, setInitialPublishingTarget] = useState(null); const [publishingTargets, setPublishingTargets] = useState(null); const [publishingTargetsError, setPublishingTargetsError] = useState(null); const [publishEverythingAck, setPublishEverythingAck] = useState(false); const { bulkPublishCommentRequired, publishByCommitCommentRequired, publishEverythingCommentRequired } = useSelection( (state) => state.uiConfig.publishing ); const [publishGitFormData, setPublishGitFormData] = useSpreadState(initialPublishGitFormData); const publishGitFormValid = !isBlank(publishGitFormData.publishingTarget) && (!publishByCommitCommentRequired || !isBlank(publishGitFormData.comment)) && publishGitFormData.commitIds.replace(/\s/g, '') !== ''; const [publishStudioFormData, setPublishStudioFormData] = useSpreadState(initialPublishStudioFormData); const publishStudioFormValid = !isBlank(publishStudioFormData.publishingTarget) && (!bulkPublishCommentRequired || !isBlank(publishStudioFormData.comment)) && publishStudioFormData.path.replace(/\s/g, '') !== ''; const [publishEverythingFormData, setPublishEverythingFormData] = useSpreadState(initialPublishEverythingFormData); const publishEverythingFormValid = publishEverythingFormData.publishingTarget !== '' && (!publishEverythingCommentRequired || !isBlank(publishEverythingFormData.comment)) && publishEverythingAck; const fnRefs = useUpdateRefs({ onSubmittingAndOrPendingChange }); // region currentFormData const currentFormData = selectedMode === 'studio' ? publishStudioFormData : selectedMode === 'git' ? publishGitFormData : publishEverythingFormData; // endregion // region currentSetFormData const currentSetFormData = selectedMode === 'studio' ? setPublishStudioFormData : selectedMode === 'git' ? setPublishGitFormData : setPublishEverythingFormData; // endregion // region currentFormValid const currentFormValid = selectedMode === 'studio' ? publishStudioFormValid : selectedMode === 'git' ? publishGitFormValid : publishEverythingFormValid; // endregion // region hasChanges const hasChanges = selectedMode === 'studio' ? publishStudioFormData.path !== initialPublishStudioFormData.path || publishStudioFormData.comment !== initialPublishStudioFormData.comment || publishStudioFormData.publishingTarget !== initialPublishingTarget : selectedMode === 'git' ? publishGitFormData.commitIds !== initialPublishGitFormData.commitIds || publishGitFormData.comment !== initialPublishGitFormData.comment || publishGitFormData.publishingTarget !== initialPublishingTarget : publishEverythingFormData.comment !== initialPublishEverythingFormData.comment || publishEverythingFormData.publishingTarget !== initialPublishingTarget; // endregion const bottomElId = useId(); const setDefaultPublishingTarget = (targets, clearData) => { if (targets.length) { const stagingEnv = targets.find((target) => target.name === 'staging'); const publishingTarget = stagingEnv?.name ?? targets[0].name; setInitialPublishingTarget(publishingTarget); setPublishGitFormData({ ...(clearData && initialPublishGitFormData), publishingTarget }); setPublishStudioFormData({ ...(clearData && initialPublishStudioFormData), publishingTarget }); setPublishEverythingFormData({ ...(clearData && initialPublishEverythingFormData), publishingTarget }); } }; useEffect(() => { fnRefs.current.onSubmittingAndOrPendingChange?.({ hasPendingChanges: hasChanges, isSubmitting }); }, [isSubmitting, hasChanges, fnRefs]); useEffect(() => { hasInitialPublishService(siteId).subscribe({ next(response) { setHasInitialPublish(response); }, error(error) { dispatch(showErrorDialog(error)); } }); fetchPublishingTargets(siteId).subscribe({ next({ publishingTargets: targets }) { setPublishingTargets(targets); // Set pre-selected environment. setDefaultPublishingTarget(targets); }, error(error) { setPublishingTargetsError(error); } }); // We only want to re-fetch the publishingTargets when the site changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [siteId]); const onSubmitPublishBy = () => { setIsSubmitting(true); const { commitIds, publishingTarget, comment } = publishGitFormData; const ids = commitIds.replace(/\s/g, '').split(','); publishByCommits(siteId, ids, publishingTarget, comment).subscribe({ next() { setIsSubmitting(false); dispatch( showSystemNotification({ message: formatMessage(messages.publishSuccess) }) ); setPublishGitFormData({ ...initialPublishGitFormData, publishingTarget }); setSelectedMode(pickMode(mode)); if (onSuccessProp) { dispatch(onSuccessProp); } }, error({ response }) { setIsSubmitting(false); dispatch( showSystemNotification({ message: response.message, options: { variant: 'error' } }) ); } }); }; const onSubmitBulkPublish = () => { const eventId = 'bulkPublishWidgetSubmit'; const studioNote = formatMessage(messages.publishStudioNote, { a: (msg) => msg[0] }); dispatch( showConfirmDialog({ body: `${formatMessage(messages.publishStudioWarning)} ${studioNote}`, onCancel: batchActions([closeConfirmDialog(), dispatchDOMEvent({ id: eventId, button: 'cancel' })]), onOk: batchActions([closeConfirmDialog(), dispatchDOMEvent({ id: eventId, button: 'ok' })]) }) ); createCustomDocumentEventListener(eventId, ({ button }) => { if (button === 'ok') { setIsSubmitting(true); const { path, publishingTarget, comment } = publishStudioFormData; bulkGoLive(siteId, path, publishingTarget, comment).subscribe({ next() { setIsSubmitting(false); setPublishStudioFormData({ ...initialPublishStudioFormData, publishingTarget }); setSelectedMode(pickMode(mode)); dispatch( showSystemNotification({ message: formatMessage(messages.bulkPublishStarted) }) ); if (onSuccessProp) { dispatch(onSuccessProp); } }, error({ response }) { setIsSubmitting(false); showSystemNotification({ message: response.message, options: { variant: 'error' } }); } }); } }); }; const onSubmitPublishEverything = () => { setIsSubmitting(true); const { publishingTarget, comment } = publishEverythingFormData; publishAll(siteId, publishingTarget, comment).subscribe({ next() { setIsSubmitting(false); dispatch( showSystemNotification({ message: formatMessage(messages.publishSuccess) }) ); setPublishEverythingFormData({ ...initialPublishEverythingFormData, publishingTarget }); setSelectedMode(pickMode(mode)); setPublishEverythingAck(false); if (onSuccessProp) { dispatch(onSuccessProp); } }, error({ response }) { setIsSubmitting(false); dispatch( showSystemNotification({ message: response.message, options: { variant: 'error' } }) ); } }); }; const onCancel = () => { setSelectedMode(pickMode(mode)); setDefaultPublishingTarget(publishingTargets, true); setPublishEverythingAck(false); if (onCancelProp) { dispatch(onCancelProp); } }; const scrollToBottom = () => document.getElementById(bottomElId).scrollIntoView({ behavior: 'smooth', block: 'end' }); const handleChange = (event) => { const newMode = event.target.value; setSelectedMode(newMode); setTimeout(scrollToBottom); }; const toggleMode = (e) => { e.preventDefault(); setSelectedMode(selectedMode === 'studio' ? 'git' : 'studio'); }; const onSubmitForm = () => { if (currentFormValid) { switch (selectedMode) { case 'studio': onSubmitBulkPublish(); break; case 'git': onSubmitPublishBy(); break; case 'everything': onSubmitPublishEverything(); break; } } else { dispatch( showSystemNotification({ message: formatMessage(messages.invalidForm) }) ); } }; const customEventId = 'dialogDismissConfirm'; const onInitialPublish = () => { dispatch( showPublishDialog({ items: [initialPublishItem], onSuccess: batchActions([closePublishDialog(), dispatchDOMEvent({ id: customEventId, type: 'publish' })]), onClosed: dispatchDOMEvent({ id: customEventId, type: 'cancel' }) }) ); createCustomDocumentEventListener(customEventId, ({ type }) => { type === 'publish' && setHasInitialPublish(true); }); }; return React.createElement( Paper, { elevation: 2 }, showHeader && React.createElement(DialogHeader, { title: React.createElement(FormattedMessage, { id: 'publishOnDemand.title', defaultMessage: 'Publish on Demand' }) }), React.createElement( 'div', { className: classes.content }, hasInitialPublish ? React.createElement( React.Fragment, null, React.createElement( Paper, { elevation: 0, className: classes.modeSelector }, React.createElement( 'form', null, React.createElement( RadioGroup, { value: selectedMode ?? '', onChange: handleChange }, (nou(mode) || mode.includes('studio')) && React.createElement(FormControlLabel, { disabled: isSubmitting, value: 'studio', control: React.createElement(Radio, null), label: React.createElement(ListItemText, { primary: React.createElement(FormattedMessage, { id: 'publishOnDemand.pathModeDescription', defaultMessage: 'Publish changes made in Studio via the UI' }), secondary: 'By path' }), className: classes.byPathModeSelector }), (nou(mode) || mode.includes('git')) && React.createElement(FormControlLabel, { disabled: isSubmitting, value: 'git', control: React.createElement(Radio, null), label: React.createElement(ListItemText, { primary: React.createElement(FormattedMessage, { id: 'publishOnDemand.tagsModeDescription', defaultMessage: 'Publish changes made via direct git actions against the repository or pulled from a remote repository' }), secondary: 'By tags or commit ids' }) }), (nou(mode) || mode.includes('everything')) && React.createElement(FormControlLabel, { disabled: isSubmitting, value: 'everything', control: React.createElement(Radio, null), label: React.createElement(ListItemText, { primary: React.createElement(FormattedMessage, { id: 'publishOnDemand.publishAllDescription', defaultMessage: 'Publish everything' }), secondary: 'Publish all changes on the repo to the publishing target you choose' }) }) ) ) ), React.createElement( Collapse, { in: nnou(selectedMode), timeout: 300, unmountOnExit: true, className: classes.formContainer, onEntered: scrollToBottom }, React.createElement(PublishOnDemandForm, { disabled: isSubmitting, formData: currentFormData, setFormData: currentSetFormData, mode: selectedMode, publishingTargets: publishingTargets, publishingTargetsError: publishingTargetsError, bulkPublishCommentRequired: bulkPublishCommentRequired, publishByCommitCommentRequired: publishByCommitCommentRequired }), selectedMode === 'everything' ? React.createElement( Alert, { severity: 'warning', icon: false, sx: { [`.${alertClasses.message}`]: { overflow: 'visible' } } }, React.createElement(FormControlLabel, { control: React.createElement(Checkbox, { checked: publishEverythingAck, color: 'primary', onChange: (e, checked) => setPublishEverythingAck(checked) }), label: React.createElement(FormattedMessage, { defaultMessage: 'I understand the entire site will be published.' }) }) ) : React.createElement( 'div', { className: classes.noteContainer }, React.createElement( Typography, { variant: 'caption', className: classes.note }, selectedMode === 'studio' ? React.createElement(FormattedMessage, { id: 'publishingDashboard.studioNote', defaultMessage: 'Publishing by path should be used to publish changes made in Studio via the UI. For changes made via direct git actions, please <a>publish by commit or tag</a>.', values: { a: (msg) => React.createElement( Link, { key: 'Link', href: '#', onClick: toggleMode, className: classes.noteLink }, msg[0] ) } }) : React.createElement(FormattedMessage, { id: 'publishingDashboard.gitNote', defaultMessage: 'Publishing by commit or tag must be used for changes made via direct git actions against the repository or pulled from a remote repository. For changes made via Studio on the UI, use please <a>publish by path</a>.', values: { a: (msg) => React.createElement( Link, { key: 'Link', href: '#', onClick: toggleMode, className: classes.noteLink }, msg[0] ) } }) ) ) ) ) : React.createElement( Box, { className: classes.initialPublishContainer }, React.createElement(InfoOutlinedIcon, { className: classes.initialPublishIcon }), React.createElement( Typography, { variant: 'body1', className: classes.initialPublishDescription }, React.createElement(FormattedMessage, { id: 'publishOnDemand.noInitialPublish', defaultMessage: 'The project needs to undergo its initial publish before other publishing options become available' }) ), hasPublishPermission && React.createElement( PrimaryButton, { onClick: onInitialPublish }, React.createElement(FormattedMessage, { id: 'publishOnDemand.publishEntireProject', defaultMessage: 'Publish Entire Project' }) ) ) ), selectedMode && React.createElement( DialogFooter, null, React.createElement( SecondaryButton, { onClick: onCancel, disabled: isSubmitting }, React.createElement(FormattedMessage, { defaultMessage: 'Reset' }) ), React.createElement( PrimaryButton, { loading: isSubmitting, disabled: !currentFormValid, onClick: onSubmitForm }, React.createElement(FormattedMessage, { id: 'words.publish', defaultMessage: 'Publish' }) ) ), React.createElement('div', { id: bottomElId }) ); } export default PublishOnDemandWidget;