@plone/volto
Version:
Volto
266 lines (240 loc) • 8.19 kB
JSX
import React from 'react';
import hoistNonReactStatics from 'hoist-non-react-statics';
import isEqual from 'react-fast-compare';
import { toast } from 'react-toastify';
import Icon from '@plone/volto/components/theme/Icon/Icon';
import Toast from '@plone/volto/components/manage/Toast/Toast';
import { Button } from 'semantic-ui-react';
import checkSVG from '@plone/volto/icons/check.svg';
import clearSVG from '@plone/volto/icons/clear.svg';
import { useIntl, defineMessages } from 'react-intl';
import { useLocation } from 'react-router-dom';
import config from '@plone/volto/registry';
const messages = defineMessages({
autoSaveFound: {
id: 'Autosaved content found',
defaultMessage: 'Autosaved content found',
},
loadData: {
id: 'Do you want to restore your autosaved content?',
defaultMessage: 'Do you want to restore your autosaved content?',
},
loadExpiredData: {
id: "Another person edited this content, and it's currently displayed. Do you want to replace it with your autosaved content?",
defaultMessage:
"Another person edited this content, and it's currently displayed. Do you want to replace it with your autosaved content?",
},
});
function getDisplayName(WrappedComponent) {
return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
const mapSchemaToData = (schema, data) => {
if (!data) return {};
const dataKeys = Object.keys(data);
return Object.assign(
{},
...Object.keys(schema.properties)
.filter((k) => dataKeys.includes(k))
.map((k) => ({ [k]: data[k] })),
);
};
// will be used to avoid using the first mount call if there is a second call
let mountTime;
const getFormId = (props, location) => {
const { type, pathname = location.pathname, isEditForm, schema } = props;
const id = isEditForm
? ['form', type, pathname].join('-')
: type
? ['form', pathname, type].join('-')
: schema?.properties?.comment
? ['form', pathname, 'comment'].join('-')
: ['form', pathname].join('-');
return id;
};
/**
* Toast content that has OK and Cancel buttons
* @param {function} onUpdate
* @param {function} onClose
* @param {string} userMessage
* @returns
*/
const ConfirmAutoSave = ({ onUpdate, onClose, userMessage }) => {
const handleClickOK = () => onUpdate();
const handleClickCancel = () => onClose();
return (
<div className="toast-box-center">
<div>{userMessage}</div>
<Button
icon
aria-label="Unchecked"
className="save toast-box"
onClick={handleClickOK}
>
<Icon
name={checkSVG}
size="24px"
className="circled toast-box-blue-icon"
/>
</Button>
<Button
icon
aria-label="Unchecked"
className="save toast-box"
onClick={handleClickCancel}
>
<Icon
name={clearSVG}
size="24px"
className="circled toast-box-blue-icon"
/>
</Button>
</div>
);
};
/**
* Will remove localStorage item using debounce
* @param {string} id
* @param {number} timerForDeletion
*/
const clearStorage = (id, timerForDeletion) => {
timerForDeletion.current && clearTimeout(timerForDeletion.current);
timerForDeletion.current = setTimeout(() => {
localStorage.removeItem(id);
}, 500);
};
/**
* Stale if server date is more recent
* @param {string} serverModifiedDate
* @param {string} autoSaveDate
* @returns {Boolean}
*/
const autoSaveFoundIsStale = (serverModifiedDate, autoSaveDate) => {
const result = !serverModifiedDate
? false
: new Date(serverModifiedDate) > new Date(autoSaveDate);
return result;
};
const draftApi = (id, schema, timer, timerForDeletion, intl) => ({
// - since Add Content Type will call componentDidMount twice, we will
// use the second call (using debounce)- the first will ignore any setState comands;
// - Delete local data only if user confirms Cancel
// - Will tell user that it has local stored data, even if its less recent than the server data
checkSavedDraft(state, updateCallback) {
if (!schema) return;
const saved = localStorage.getItem(id);
if (saved && Object.keys(JSON.parse(saved)).length > 1) {
const formData = mapSchemaToData(schema, state);
// includes autoSaveDate
const foundSavedData = JSON.parse(saved);
// includes only form data found in schema (no autoSaveDate)
const foundSavedSchemaData = mapSchemaToData(schema, foundSavedData);
if (!isEqual(formData, foundSavedSchemaData)) {
// eslint-disable-next-line no-alert
// cancel existing setTimeout to avoid using first call if
// successive calls are made
mountTime && clearTimeout(mountTime);
mountTime = setTimeout(() => {
toast.info(
<Toast
position="top-right"
info
autoClose={false}
title={intl.formatMessage(messages.autoSaveFound)}
content={
<ConfirmAutoSave
onUpdate={() => updateCallback(foundSavedSchemaData)}
onClose={() => clearStorage(id, timerForDeletion)}
userMessage={
autoSaveFoundIsStale(
state.modified,
foundSavedData.autoSaveDate,
)
? intl.formatMessage(messages.loadExpiredData)
: intl.formatMessage(messages.loadData)
}
/>
}
/>,
);
}, 300);
}
}
},
// use debounce mode
onSaveDraft(state) {
if (!schema) return;
timer.current && clearTimeout(timer.current);
timer.current = setTimeout(() => {
const formData = mapSchemaToData(schema, state);
const saved = localStorage.getItem(id);
const newData = JSON.parse(saved);
localStorage.setItem(
id,
JSON.stringify({
...newData,
...formData,
autoSaveDate: new Date(),
}),
);
}, 300);
},
onCancelDraft() {
if (!schema) return;
clearStorage(id, timerForDeletion);
},
});
export default function withSaveAsDraft(options) {
const { forwardRef } = options;
return (WrappedComponent) => {
function WithSaveAsDraft(props) {
const { schema } = props;
const intl = useIntl();
const location = useLocation();
const id = getFormId(props, location);
const timmeRef = React.useRef();
const timmerForDeletionRef = React.useRef();
const api = React.useMemo(
() => draftApi(id, schema, timmeRef, timmerForDeletionRef, intl),
[id, schema, timmeRef, timmerForDeletionRef, intl],
);
return (
<WrappedComponent
{...props}
{...api}
ref={forwardRef ? props.forwardedRef : null}
/>
);
}
WithSaveAsDraft.displayName = `WithSaveAsDraft(${getDisplayName(
WrappedComponent,
)})`;
const DraftWrappedComponent = forwardRef
? hoistNonReactStatics(
React.forwardRef((props, ref) => (
<WithSaveAsDraft {...props} forwardedRef={ref} />
)),
WrappedComponent,
)
: hoistNonReactStatics(WithSaveAsDraft, WrappedComponent);
const WithExperimentalSaveAsDraft = forwardRef
? hoistNonReactStatics(
React.forwardRef((props, ref) => {
const ComponentToRender = config.experimental?.saveAsDraft?.enabled
? DraftWrappedComponent
: WrappedComponent;
return <ComponentToRender {...props} ref={ref} />;
}),
WrappedComponent,
)
: hoistNonReactStatics((props) => {
const ComponentToRender = config.experimental?.saveAsDraft?.enabled
? DraftWrappedComponent
: WrappedComponent;
return <ComponentToRender {...props} />;
}, WrappedComponent);
WithExperimentalSaveAsDraft.displayName = `WithExperimentalSaveAsDraft(${getDisplayName(
WrappedComponent,
)})`;
return WithExperimentalSaveAsDraft;
};
}