@craftercms/studio-ui
Version:
Services, components, models & utils to build CrafterCMS authoring extensions.
371 lines (369 loc) • 14.6 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 { useSpreadState } from '../../hooks/useSpreadState';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { paths } from './utils';
import { useActiveSiteId } from '../../hooks/useActiveSiteId';
import { useDispatch } from 'react-redux';
import { fetchPublishingTargets } from '../../services/publishing';
import { getComputedPublishingTarget, getDateScheduled } from '../../utils/content';
import { FormattedMessage } from 'react-intl';
import { createPresenceTable } from '../../utils/array';
import { fetchDependencies } from '../../services/dependencies';
import { PublishDialogUI } from './PublishDialogUI';
import useStyles from './styles';
import { useSelection } from '../../hooks/useSelection';
import { isBlank } from '../../utils/string';
import { updatePublishDialog } from '../../state/actions/dialogs';
import { approve, publish, requestPublish } from '../../services/workflow';
import { fetchDetailedItems } from '../../services/content';
import { fetchDetailedItemComplete } from '../../state/actions/content';
import { createAtLeastHalfHourInFutureDate } from '../../utils/datetime';
export function PublishDialogContainer(props) {
const { items, scheduling = 'now', onSuccess, onClose, isSubmitting } = props;
const [detailedItems, setDetailedItems] = useState();
const [isFetchingItems, setIsFetchingItems] = useState(false);
const [state, setState] = useSpreadState({
emailOnApprove: false,
requestApproval: false,
publishingTarget: '',
submissionComment: '',
scheduling,
scheduledDateTime: createAtLeastHalfHourInFutureDate(),
publishingChannel: null,
error: null,
fetchingDependencies: false
});
const [published, setPublished] = useState(null);
const [publishingTargets, setPublishingTargets] = useState(null);
const [publishingTargetsStatus, setPublishingTargetsStatus] = useState('Loading');
const [selectedItems, setSelectedItems] = useState({});
const [dependencies, setDependencies] = useState(null);
const [submitDisabled, setSubmitDisabled] = useState(true);
const siteId = useActiveSiteId();
const hasPublishPermission = !items?.some((item) => !item.availableActionsMap.publish);
const dispatch = useDispatch();
const submissionCommentRequired = useSelection((state) => state.uiConfig.publishing.publishCommentRequired);
const isApprove = hasPublishPermission && items.every((item) => item.stateMap.submitted);
const submit = !hasPublishPermission || state.requestApproval ? requestPublish : isApprove ? approve : publish;
const { mixedPublishingTargets, mixedPublishingDates, dateScheduled, publishingTarget } = useMemo(() => {
const state = {
mixedPublishingTargets: false,
mixedPublishingDates: false,
dateScheduled: null,
publishingTarget: null
};
if (detailedItems) {
const itemsChecked = detailedItems.flatMap((item) => (selectedItems[item.path] ? [item] : []));
if (itemsChecked.length === 0) {
state.publishingTarget = '';
return state;
}
// region Discover mixed targets and/or schedules and sets the publishingTarget based off the items
let target;
let schedule;
itemsChecked.some((item, index) => {
const computedTarget = getComputedPublishingTarget(itemsChecked[0]);
const computedSchedule = getDateScheduled(itemsChecked[0]);
if (index === 0) {
target = computedTarget;
schedule = computedSchedule;
} else {
if (target !== computedTarget) {
// If the computed target is different, we have mixed targets.
// Could be any combination of live vs staging vs null that triggers mixed targets.
state.mixedPublishingTargets = true;
}
if (schedule !== computedSchedule) {
// If the current item's computed scheduled date is different, we have mixed dates.
// Could be any combination of live vs staging vs null that triggers mixed targets.
state.mixedPublishingDates = true;
}
}
if (state.publishingTarget === null && computedTarget !== null) {
state.publishingTarget = computedTarget;
}
// First found dateScheduled cached for later
if (state.dateScheduled === null && computedSchedule !== null) {
state.dateScheduled = computedSchedule;
}
// Once these things are found to be true, no need to iterate further.
return state.mixedPublishingTargets && state.mixedPublishingDates && state.dateScheduled !== null;
});
// endregion
// If there aren't any available target (or they haven't loaded), dialog should not have a selected target.
if (publishingTargets?.length) {
// If there are mixed targets, we want manual user selection of a target.
// Otherwise, use what was previously found as the target on the selected items.
if (state.mixedPublishingTargets) {
state.publishingTarget = '';
} else {
// If we haven't found a target by this point, we wish to default the dialog to
// staging (as long as that target is enabled in the system, which is checked next).
if (state.publishingTarget === null) {
state.publishingTarget = 'staging';
}
state.publishingTarget = publishingTargets.some((target) => target.name === state.publishingTarget)
? state.publishingTarget
: publishingTargets[0].name;
}
} else {
state.publishingTarget = '';
}
}
return state;
}, [selectedItems, publishingTargets, detailedItems]);
const getPublishingChannels = useCallback(
(success, error) => {
setPublishingTargetsStatus('Loading');
fetchPublishingTargets(siteId).subscribe({
next({ publishingTargets: targets, published }) {
setPublished(published);
setPublishingTargets(targets);
setPublishingTargetsStatus('Success');
success?.(targets);
},
error(e) {
setPublishingTargetsStatus('Error');
error?.(e);
}
});
},
[siteId]
);
useEffect(() => {
getPublishingChannels(() => {
setSelectedItems(createPresenceTable(items, true, (item) => item.path));
});
}, [getPublishingChannels, items]);
useEffect(() => {
setState({ scheduling });
}, [scheduling, setState]);
useEffect(() => {
const partialState = {
publishingTarget,
scheduling: dateScheduled || scheduling !== 'now' ? 'custom' : 'now'
};
if (dateScheduled) {
partialState.scheduledDateTime = dateScheduled;
}
setState(partialState);
}, [dateScheduled, publishingTarget, setState, scheduling]);
useEffect(() => {
// TODO: This could be optimised to run the least expensive checks first.
// Submit button should be disabled:
setSubmitDisabled(
// While submitting
isSubmitting ||
// When no items are selected
!Object.values(selectedItems).filter(Boolean).length ||
// When there are no available/loaded publishing targets
!publishingTargets?.length ||
// When no publishing target is selected
!state.publishingTarget ||
// If submission comment is required (per config) and blank
(submissionCommentRequired && isBlank(state.submissionComment)) ||
// When there's an error
Boolean(state.error) ||
// The scheduled date is in the past
state.scheduledDateTime < new Date()
);
}, [
isSubmitting,
selectedItems,
publishingTargets,
state.publishingTarget,
state.submissionComment,
state.scheduledDateTime,
submissionCommentRequired,
state.error
]);
useEffect(() => {
setIsFetchingItems(true);
fetchDetailedItems(
siteId,
items.map((item) => item.path)
).subscribe({
next(response) {
setDetailedItems(response);
response.forEach((item) => {
dispatch(fetchDetailedItemComplete(item));
});
setIsFetchingItems(false);
},
error(error) {
setState({
error: error.response?.response ?? error
});
setIsFetchingItems(false);
}
});
}, [items, siteId, setState, dispatch]);
const handleSubmit = () => {
const {
publishingTarget,
scheduling: schedule,
emailOnApprove: sendEmail,
submissionComment,
scheduledDateTime: scheduledDate
} = state;
const items = Object.entries(selectedItems)
.filter(([, isChecked]) => isChecked)
.map(([path]) => path);
const data = {
publishingTarget,
items,
sendEmailNotifications: sendEmail,
comment: submissionComment,
...(schedule === 'custom' ? { schedule: scheduledDate } : {})
};
dispatch(updatePublishDialog({ isSubmitting: true }));
submit(siteId, data).subscribe(
() => {
dispatch(updatePublishDialog({ isSubmitting: false, hasPendingChanges: false }));
onSuccess?.({
schedule: schedule,
publishingTarget,
// @ts-expect-error: TODO: Not quite sure if users of this dialog are making use of the `environment` prop name. Should use `publishingTarget` instead.
environment: publishingTarget,
type: !hasPublishPermission || state.requestApproval ? 'submit' : 'publish',
items: items.map((path) => props.items.find((item) => item.path === path))
});
},
(error) => {
dispatch(updatePublishDialog({ isSubmitting: false }));
}
);
};
const onItemClicked = (e, path) => {
e.stopPropagation();
e.preventDefault();
setSelectedItems({ ...selectedItems, [path]: !selectedItems[path] });
};
const onSelectAll = () => {
setSelectedItems(
items.reduce(
(checked, item) => {
checked[item.path] = true;
return checked;
},
{ ...selectedItems }
)
);
};
function onSelectAllSoft() {
// If one that is not checked is found, check all. Otherwise, uncheck all.
const check = Boolean(dependencies.softDependencies.find((path) => !selectedItems[path]));
setSelectedItems(
dependencies.softDependencies.reduce(
(nextCheckedSoftDependencies, path) => {
nextCheckedSoftDependencies[path] = check;
return nextCheckedSoftDependencies;
},
{ ...selectedItems }
)
);
}
function onFetchDependenciesClick() {
setState({ fetchingDependencies: true });
fetchDependencies(siteId, paths(selectedItems)).subscribe(
(items) => {
setState({ fetchingDependencies: false });
setDependencies(items);
},
() => {
setState({ fetchingDependencies: false });
setDependencies(null);
}
);
}
const onPublishingArgumentChange = (e) => {
let value;
switch (e.target.type) {
case 'checkbox':
value = e.target.checked;
break;
case 'textarea':
value = e.target.value;
dispatch(updatePublishDialog({ hasPendingChanges: true }));
break;
case 'radio':
value = e.target.value;
break;
case 'dateTimePicker': {
value = e.target.value;
break;
}
default:
console.error('Publishing argument change event ignored.');
return;
}
setState({ [e.target.name]: value });
};
const onCloseButtonClick = (e) => onClose(e, null);
return React.createElement(PublishDialogUI, {
published: published,
items: detailedItems,
publishingTargets: publishingTargets,
isFetching: isFetchingItems,
error: state.error,
publishingTargetsStatus: publishingTargetsStatus,
onPublishingChannelsFailRetry: getPublishingChannels,
onCloseButtonClick: onCloseButtonClick,
handleSubmit: handleSubmit,
state: state,
isSubmitting: isSubmitting,
selectedItems: selectedItems,
onItemClicked: onItemClicked,
dependencies: dependencies,
onSelectAll: onSelectAll,
onSelectAllSoftDependencies: onSelectAllSoft,
onClickShowAllDeps: onFetchDependenciesClick,
classes: useStyles().classes,
isRequestPublish: !hasPublishPermission || state.requestApproval,
showRequestApproval: hasPublishPermission && items.every((item) => !item.stateMap.submitted),
submitLabel:
state.scheduling === 'custom'
? React.createElement(FormattedMessage, { id: 'words.schedule', defaultMessage: 'Schedule' })
: !hasPublishPermission || state.requestApproval
? React.createElement(FormattedMessage, {
id: 'publishDialog.requestPublish',
defaultMessage: 'Request Publish'
})
: React.createElement(FormattedMessage, { id: 'words.publish', defaultMessage: 'Publish' }),
mixedPublishingTargets: mixedPublishingTargets,
mixedPublishingDates: mixedPublishingDates,
submissionCommentRequired: submissionCommentRequired,
submitDisabled: submitDisabled,
onPublishingArgumentChange: onPublishingArgumentChange
});
}
export default PublishDialogContainer;