UNPKG

@atlaskit/editor-plugin-synced-block

Version:

SyncedBlock plugin for @atlaskit/editor-core

289 lines (283 loc) 14.5 kB
/* 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)))); };