@atlaskit/editor-plugin-synced-block
Version:
SyncedBlock plugin for @atlaskit/editor-core
289 lines (283 loc) • 14.5 kB
JavaScript
/* DeleteConfirmationModal.tsx generated by @compiled/babel-plugin v2.0.0 */
import "./DeleteConfirmationModal.compiled.css";
import { ax, ix } from "@compiled/react/runtime";
import React, { useCallback, useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import Button from '@atlaskit/button/new';
import { useSharedPluginStateWithSelector } from '@atlaskit/editor-common/hooks';
import { syncBlockMessages as messages } from '@atlaskit/editor-common/messages';
import { isOfflineMode } from '@atlaskit/editor-plugin-connectivity';
import ModalDialog, { ModalBody, ModalFooter, ModalHeader, ModalTitle, ModalTransition } from '@atlaskit/modal-dialog';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { Text, Box } from '@atlaskit/primitives/compiled';
import Spinner from '@atlaskit/spinner';
import { syncedBlockPluginKey } from '../pm-plugins/main';
const modalContentMap = {
'source-block-deleted': {
titleMultiple: messages.deleteConfirmationModalTitleMultiple,
titleSingle: messages.deletionConfirmationModalTitleSingle,
descriptionSingle: messages.deletionConfirmationModalDescriptionNoRef,
descriptionMultiple: messages.deletionConfirmationModalDescriptionNew,
confirmButtonLabel: messages.deleteConfirmationModalDeleteButton
},
'source-block-unpublished': {
titleMultiple: messages.deleteConfirmationModalTitleMultiple,
titleSingle: messages.deletionConfirmationModalTitleSingle,
descriptionSingle: messages.deletionConfirmationModalDescriptionNoRef,
descriptionMultiple: messages.deletionConfirmationModalDescriptionNew,
confirmButtonLabel: messages.deleteConfirmationModalDeleteButton
},
'source-block-unsynced': {
titleMultiple: messages.unsyncConfirmationModalTitle,
titleSingle: messages.unsyncConfirmationModalTitle,
descriptionSingle: messages.unsyncConfirmModalDescriptionSingle,
descriptionMultiple: messages.unsyncConfirmModalDescriptionMultipleNew,
confirmButtonLabel: messages.deleteConfirmationModalUnsyncButton
}
};
const styles = {
spinner: "_1mou1wug _195g1wug"
};
export const DeleteConfirmationModal = ({
syncBlockStoreManager,
api,
editorView
}) => {
var _api$core2, _api$decorations2, _api$core5, _api$core8;
const [isOpen, setIsOpen] = useState(false);
const [syncBlockIds, setSyncBlockIds] = useState(undefined);
const [referenceCount, setReferenceCount] = useState(undefined);
const [deleteReason, setDeleteReason] = useState('source-block-deleted');
const {
mode,
bodiedSyncBlockDeletionStatus,
activeFlag
} = useSharedPluginStateWithSelector(api, ['connectivity', 'syncedBlock'], states => {
var _states$connectivityS, _states$syncedBlockSt, _states$syncedBlockSt2;
return {
mode: (_states$connectivityS = states.connectivityState) === null || _states$connectivityS === void 0 ? void 0 : _states$connectivityS.mode,
bodiedSyncBlockDeletionStatus: (_states$syncedBlockSt = states.syncedBlockState) === null || _states$syncedBlockSt === void 0 ? void 0 : _states$syncedBlockSt.bodiedSyncBlockDeletionStatus,
activeFlag: (_states$syncedBlockSt2 = states.syncedBlockState) === null || _states$syncedBlockSt2 === void 0 ? void 0 : _states$syncedBlockSt2.activeFlag
};
});
const {
formatMessage
} = useIntl();
const resolverRef = React.useRef(undefined);
// When a source block with no references is deleted, the modal is never shown but
// onDeleteCompleted still sets bodiedSyncBlockDeletionStatus to 'completed'. This ref signals
// the useEffect to silently reset the status without trying to close the modal (which was never open).
const skipModalOnCompletedRef = React.useRef(false);
const handleClick = useCallback(confirm => () => {
var _api$core;
if (resolverRef.current) {
resolverRef.current(confirm);
resolverRef.current = undefined;
}
if (!confirm) {
setIsOpen(false);
setReferenceCount(undefined);
}
api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(({
tr
}) => {
return tr.setMeta(syncedBlockPluginKey, {
bodiedSyncBlockDeletionStatus: confirm ? 'processing' : 'none',
activeFlag: false
});
});
// The delete menu adds a `danger` decoration that it only clears on mouse leave, which
// never fires because clicking Delete closes the menu. It stays for the lifetime of this
// modal so the block being deleted remains highlighted, and confirming drops it along
// with the node, but cancelling has to clear it or the red highlight is left behind.
if (!confirm && editorView && fg('platform_editor_blocks_patch_7')) {
var _api$decorations;
api === null || api === void 0 ? void 0 : (_api$decorations = api.decorations) === null || _api$decorations === void 0 ? void 0 : _api$decorations.actions.removeDecoration(editorView.state, editorView.dispatch);
}
}, [api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions, api === null || api === void 0 ? void 0 : (_api$decorations2 = api.decorations) === null || _api$decorations2 === void 0 ? void 0 : _api$decorations2.actions, editorView]);
// Store activeFlag in a ref so confirmationCallback always reads the latest value
// even if it changes during the await fetchReferenceCountRef call.
const activeFlagRef = React.useRef(activeFlag);
activeFlagRef.current = activeFlag;
// Use a ref so fetchReferenceCount can be called from confirmationCallback without
// being added to its dependency array — preventing confirmationCallback from being
// recreated mid-flight during an await (which would break the promise chain).
// The assignment during render is intentional — this is a standard React ref-as-latest-value
// pattern. The reduce over syncBlockIds (typically 1-2 items) is not actually expensive.
const fetchReferenceCountRef = React.useRef(() => Promise.resolve(0));
fetchReferenceCountRef.current = async syncBlockIds => {
const references = await Promise.all(syncBlockIds.map(async syncBlockId => {
var _result$references$le, _result$references;
const result = await syncBlockStoreManager.sourceManager.fetchReferences(syncBlockId.resourceId);
if (result !== null && result !== void 0 && result.error) {
// Consider fetch fails as soon as one of the fetches fails
throw new Error();
}
return (_result$references$le = (_result$references = result.references) === null || _result$references === void 0 ? void 0 : _result$references.length) !== null && _result$references$le !== void 0 ? _result$references$le : 0;
}));
// eslint-disable-next-line @atlassian/perf-linting/no-expensive-computations-in-render
return references.reduce((sum, count) => sum + count, 0);
};
const confirmationCallback = useCallback(async (syncBlockIds, deleteReason) => {
// Fetch references before opening the modal. If none exist, skip the modal
// entirely and auto-confirm the deletion. On fetch error, default to showing
// the modal to avoid accidental data loss.
let count;
try {
count = await fetchReferenceCountRef.current(syncBlockIds);
} catch {
count = 1;
}
if (count === 0) {
var _api$core3;
// No references — auto-confirm without showing the modal.
// Clear activeFlag to avoid issues with subsequent deletion attempts.
// We do NOT reset bodiedSyncBlockDeletionStatus here because onDeleteCompleted
// will set it to 'completed' after the delete call returns. Instead we use a
// ref to signal that the next 'completed' status should be silently reset
// without trying to close the modal.
skipModalOnCompletedRef.current = true;
api === null || api === void 0 ? void 0 : (_api$core3 = api.core) === null || _api$core3 === void 0 ? void 0 : _api$core3.actions.execute(({
tr
}) => {
return tr.setMeta(syncedBlockPluginKey, {
...(activeFlagRef.current ? {
activeFlag: false
} : {})
});
});
return true;
}
setReferenceCount(count);
setIsOpen(true);
setSyncBlockIds(syncBlockIds);
if (deleteReason) {
setDeleteReason(deleteReason);
}
const confirmedPromise = new Promise(resolve => {
resolverRef.current = resolve;
});
if (activeFlagRef.current) {
var _api$core4;
api === null || api === void 0 ? void 0 : (_api$core4 = api.core) === null || _api$core4 === void 0 ? void 0 : _api$core4.actions.execute(({
tr
}) => {
return tr.setMeta(syncedBlockPluginKey, {
// Clear flag to avoid potential retry deletion of different blocks
activeFlag: false
});
});
}
return confirmedPromise;
},
// fetchReferenceCountRef and activeFlagRef are intentionally omitted — they are refs
// and never change identity, ensuring confirmationCallback is never recreated mid-flight
// eslint-disable-next-line react-hooks/exhaustive-deps
[api === null || api === void 0 ? void 0 : (_api$core5 = api.core) === null || _api$core5 === void 0 ? void 0 : _api$core5.actions]);
useEffect(() => {
const unregister = syncBlockStoreManager.sourceManager.registerConfirmationCallback(confirmationCallback);
return () => {
unregister();
};
}, [syncBlockStoreManager, confirmationCallback]);
useEffect(() => {
if (skipModalOnCompletedRef.current) {
if (bodiedSyncBlockDeletionStatus === 'completed') {
var _api$core6;
// Deletion was auto-confirmed without showing the modal (no references).
// Reset the status to 'none' — keep skipModalOnCompletedRef true until
// we confirm the status has landed as 'none' to guard against the race where
// the next deletion opens the modal before this transaction is processed.
api === null || api === void 0 ? void 0 : (_api$core6 = api.core) === null || _api$core6 === void 0 ? void 0 : _api$core6.actions.execute(({
tr
}) => {
return tr.setMeta(syncedBlockPluginKey, {
bodiedSyncBlockDeletionStatus: 'none'
});
});
} else if (bodiedSyncBlockDeletionStatus === 'none') {
skipModalOnCompletedRef.current = false;
} else if (bodiedSyncBlockDeletionStatus === 'processing' && isOpen) {
// Reset on deletion failure (e.g. network error) to avoid modal stuck in opened state.
skipModalOnCompletedRef.current = false;
}
return;
}
if (bodiedSyncBlockDeletionStatus === 'completed' && isOpen) {
var _api$core7;
// auto close modal once deletion is successful
// eslint-disable-next-line @atlassian/perf-linting/no-chain-state-updates -- Ignored via go/ees017 (to be fixed)
setIsOpen(false);
api === null || api === void 0 ? void 0 : (_api$core7 = api.core) === null || _api$core7 === void 0 ? void 0 : _api$core7.actions.execute(({
tr
}) => {
return tr.setMeta(syncedBlockPluginKey, {
// Reset deletion status to have a clean state for next deletion
bodiedSyncBlockDeletionStatus: 'none'
});
});
}
}, [api === null || api === void 0 ? void 0 : (_api$core8 = api.core) === null || _api$core8 === void 0 ? void 0 : _api$core8.actions, bodiedSyncBlockDeletionStatus, isOpen]);
// References are fetched and set before the modal opens in
// confirmationCallback, so no additional fetch is needed here.
return /*#__PURE__*/React.createElement(ModalTransition, null, isOpen && /*#__PURE__*/React.createElement(ModalDialog, {
onClose: handleClick(false),
testId: "sync-block-delete-confirmation",
height: 184
}, /*#__PURE__*/React.createElement(React.Fragment, null, referenceCount === undefined ? /*#__PURE__*/React.createElement(Box, {
xcss: styles.spinner
}, /*#__PURE__*/React.createElement(Spinner, {
size: "large"
})) : /*#__PURE__*/React.createElement(ModalContent, {
content: modalContentMap[deleteReason],
referenceCount: referenceCount,
handleClick: handleClick,
formatMessage: formatMessage,
isDeleting: bodiedSyncBlockDeletionStatus === 'processing',
isDisabled: isOfflineMode(mode),
deleteReason: deleteReason,
sourceCount: (syncBlockIds === null || syncBlockIds === void 0 ? void 0 : syncBlockIds.length) || 0
}))));
};
const ModalContent = ({
content,
referenceCount,
handleClick,
formatMessage,
isDeleting,
isDisabled,
deleteReason,
sourceCount
}) => {
const {
titleMultiple,
titleSingle,
descriptionSingle,
descriptionMultiple,
confirmButtonLabel
} = content;
const hasNoReferenceOrFailToFetch = referenceCount === 0;
const totalLocationCount = deleteReason === 'source-block-deleted' ? referenceCount + sourceCount : referenceCount;
const isReferenceCountDescriptionEnabled = deleteReason === 'source-block-deleted' && fg('platform_editor_blocks_patch_7');
const descriptionLocationCount = isReferenceCountDescriptionEnabled ? referenceCount : totalLocationCount;
const descriptionMultipleMessage = isReferenceCountDescriptionEnabled ? messages.deletionConfirmationModalDescriptionReferenceCount : descriptionMultiple;
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ModalHeader, {
hasCloseButton: true
}, /*#__PURE__*/React.createElement(ModalTitle, {
appearance: "warning"
}, hasNoReferenceOrFailToFetch ? formatMessage(titleSingle) : formatMessage(titleMultiple, {
count: totalLocationCount
}))), /*#__PURE__*/React.createElement(ModalBody, null, /*#__PURE__*/React.createElement(Text, null, hasNoReferenceOrFailToFetch ? formatMessage(descriptionSingle) : formatMessage(descriptionMultipleMessage, {
syncBlockCount: descriptionLocationCount
}))), /*#__PURE__*/React.createElement(ModalFooter, null, /*#__PURE__*/React.createElement(Button, {
appearance: "subtle",
onClick: handleClick(false)
}, formatMessage(messages.deleteConfirmationModalCancelButton)), /*#__PURE__*/React.createElement(Button, {
appearance: "warning",
onClick: handleClick(true),
autoFocus: true,
isDisabled: isDisabled,
isLoading: isDeleting,
testId: `synced-block-delete-confirmation-modal-${deleteReason}-button`
}, formatMessage(confirmButtonLabel))));
};