Move email editor components out of the engine folder

MAILPOET-6215
This commit is contained in:
Oluwaseun Olorunsola
2024-11-11 09:41:48 +01:00
committed by Oluwaseun Olorunsola
parent e6d607028c
commit 1c3ea9cd0a
96 changed files with 12 additions and 12 deletions

View File

@@ -0,0 +1,86 @@
import { useRef } from '@wordpress/element';
import {
Button,
Dropdown,
VisuallyHidden,
__experimentalText as Text, // eslint-disable-line
TextControl,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { chevronDown } from '@wordpress/icons';
import { useSelect } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { storeName } from '../../store';
// @see https://github.com/WordPress/gutenberg/blob/5e0ffdbc36cb2e967dfa6a6b812a10a2e56a598f/packages/edit-post/src/components/header/document-actions/index.js
export function CampaignName() {
const { showIconLabels } = useSelect(
( select ) => ( {
showIconLabels:
select( storeName ).isFeatureActive( 'showIconLabels' ),
postId: select( storeName ).getEmailPostId(),
} ),
[]
);
const [ emailTitle = '', setTitle ] = useEntityProp(
'postType',
'mailpoet_email',
'title'
);
const titleRef = useRef( null );
return (
<div ref={ titleRef } className="mailpoet-email-editor-campaign-name">
<Dropdown
popoverProps={ {
placement: 'bottom',
anchor: titleRef.current,
} }
contentClassName="mailpoet-email-editor-campaign-name__dropdown"
renderToggle={ ( { isOpen, onToggle } ) => (
<>
<Button
onClick={ onToggle }
className="mailpoet-email-campaign-name__link"
>
<Text size="body" as="h1">
<VisuallyHidden as="span">
{ __( 'Editing email:', 'mailpoet' ) }
</VisuallyHidden>
{ emailTitle }
</Text>
</Button>
<Button
className="mailpoet-email-campaign-name__toggle"
icon={ chevronDown }
aria-expanded={ isOpen }
aria-haspopup="true"
onClick={ onToggle }
label={ __( 'Change campaign name', 'mailpoet' ) }
>
{ showIconLabels && __( 'Rename', 'mailpoet' ) }
</Button>
</>
) }
renderContent={ () => (
<div className="mailpoet-email-editor-email-title-edit">
<TextControl
label={ __( 'Campaign name', 'mailpoet' ) }
value={ emailTitle }
onChange={ ( newTitle ) => {
setTitle( newTitle );
} }
name="campaign_name"
help={ __(
`Name your email campaign to indicate its purpose. This would only be visible to you and not shown to your subscribers.`,
'mailpoet'
) }
/>
</div>
) }
/>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useRef, useState } from '@wordpress/element';
import { PinnedItems } from '@wordpress/interface';
import { Button, ToolbarItem as WpToolbarItem } from '@wordpress/components';
import {
NavigableToolbar,
BlockToolbar as WPBlockToolbar,
store as blockEditorStore,
} from '@wordpress/block-editor';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as coreDataStore } from '@wordpress/core-data';
// @ts-expect-error DocumentBar types are not available
import { DocumentBar, store as editorStore } from '@wordpress/editor';
import { store as preferencesStore } from '@wordpress/preferences';
import { __ } from '@wordpress/i18n';
import { plus, listView, undo, redo, next, previous } from '@wordpress/icons';
import classnames from 'classnames';
import { storeName } from '../../store';
import { MoreMenu } from './more-menu';
import { PreviewDropdown } from '../preview';
import { SaveButton } from './save-button';
import { CampaignName } from './campaign-name';
import { SendButton } from './send-button';
import { unlock } from '../../lock-unlock';
// Build type for ToolbarItem contains only "as" and "children" properties but it takes all props from
// component passed to "as" property (in this case Button). So as fix for TS errors we need to pass all props from Button to ToolbarItem.
// We should be able to remove this fix when ToolbarItem will be fixed in Gutenberg.
const ToolbarItem = WpToolbarItem as React.ForwardRefExoticComponent<
React.ComponentProps< typeof WpToolbarItem > &
React.ComponentProps< typeof Button >
>;
// Definition of BlockToolbar in currently installed Gutenberg packages (wp-6.4) is missing hideDragHandle prop
// After updating to newer version of Gutenberg we should be able to remove this fix
const BlockToolbar = WPBlockToolbar as React.FC<
React.ComponentProps< typeof WPBlockToolbar > & {
hideDragHandle?: boolean;
}
>;
export function Header() {
const inserterButton = useRef();
const listviewButton = useRef();
const undoButton = useRef();
const redoButton = useRef();
const [ isBlockToolsCollapsed, setIsBlockToolsCollapsed ] =
useState( false );
const { toggleInserterSidebar, toggleListviewSidebar } =
useDispatch( storeName );
const { undo: undoAction, redo: redoAction } = useDispatch( coreDataStore );
const {
isInserterSidebarOpened,
isListviewSidebarOpened,
isFixedToolbarActive,
isBlockSelected,
hasUndo,
hasRedo,
hasDocumentNavigationHistory,
} = useSelect( ( select ) => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { getEditorSettings: _getEditorSettings } = unlock(
select( editorStore )
);
const editorSettings = _getEditorSettings();
return {
isInserterSidebarOpened:
select( storeName ).isInserterSidebarOpened(),
isListviewSidebarOpened:
select( storeName ).isListviewSidebarOpened(),
isFixedToolbarActive: select( preferencesStore ).get(
'core',
'fixedToolbar'
),
isBlockSelected:
!! select( blockEditorStore ).getBlockSelectionStart(),
hasUndo: select( coreDataStore ).hasUndo(),
hasRedo: select( coreDataStore ).hasRedo(),
hasDocumentNavigationHistory:
!! editorSettings.onNavigateToPreviousEntityRecord,
};
}, [] );
const preventDefault = ( event ) => {
event.preventDefault();
};
const shortLabelInserter = ! isInserterSidebarOpened
? __( 'Add', 'mailpoet' )
: __( 'Close', 'mailpoet' );
return (
<div className="editor-header edit-post-header">
<div className="editor-header__toolbar">
<NavigableToolbar
className="editor-document-tools edit-post-header-toolbar is-unstyled"
aria-label={ __( 'Email document tools', 'mailpoet' ) }
>
<div className="editor-document-tools__left">
<ToolbarItem
ref={ inserterButton }
as={ Button }
className="editor-header-toolbar__inserter-toggle edit-post-header-toolbar__inserter-toggle"
variant="primary"
isPressed={ isInserterSidebarOpened }
onMouseDown={ preventDefault }
onClick={ toggleInserterSidebar }
disabled={ false }
icon={ plus }
label={ shortLabelInserter }
showTooltip
aria-expanded={ isInserterSidebarOpened }
/>
<ToolbarItem
ref={ undoButton }
as={ Button }
className="editor-history__undo"
isPressed={ false }
onMouseDown={ preventDefault }
onClick={ undoAction }
disabled={ ! hasUndo }
icon={ undo }
label={ __( 'Undo', 'mailpoet' ) }
showTooltip
/>
<ToolbarItem
ref={ redoButton }
as={ Button }
className="editor-history__redo"
isPressed={ false }
onMouseDown={ preventDefault }
onClick={ redoAction }
disabled={ ! hasRedo }
icon={ redo }
label={ __( 'Redo', 'mailpoet' ) }
showTooltip
/>
<ToolbarItem
ref={ listviewButton }
as={ Button }
className="editor-header-toolbar__document-overview-toggle edit-post-header-toolbar__document-overview-toggle"
isPressed={ isListviewSidebarOpened }
onMouseDown={ preventDefault }
onClick={ toggleListviewSidebar }
disabled={ false }
icon={ listView }
label={ __( 'List view', 'mailpoet' ) }
showTooltip
aria-expanded={ isInserterSidebarOpened }
/>
</div>
</NavigableToolbar>
{ isFixedToolbarActive && isBlockSelected && (
<>
<div
className={ classnames(
'editor-collapsible-block-toolbar',
{
'is-collapsed': isBlockToolsCollapsed,
}
) }
>
<BlockToolbar hideDragHandle />
</div>
<Button
className="editor-header__block-tools-toggle edit-post-header__block-tools-toggle"
icon={ isBlockToolsCollapsed ? next : previous }
onClick={ () => {
setIsBlockToolsCollapsed(
( collapsed ) => ! collapsed
);
} }
label={
isBlockToolsCollapsed
? __( 'Show block tools', 'mailpoet' )
: __( 'Hide block tools', 'mailpoet' )
}
/>
</>
) }
</div>
{ ( ! isFixedToolbarActive ||
! isBlockSelected ||
isBlockToolsCollapsed ) && (
<div className="editor-header__center edit-post-header__center">
{ hasDocumentNavigationHistory ? (
<DocumentBar />
) : (
<CampaignName />
) }
</div>
) }
<div className="editor-header__settings edit-post-header__settings">
<SaveButton />
<PreviewDropdown />
<SendButton />
<PinnedItems.Slot scope={ storeName } />
<MoreMenu />
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
// Document actions - Component in header for displaying email/campaign title edit popup
.mailpoet-email-editor-campaign-name {
align-items: center;
display: flex;
flex-direction: row;
justify-content: center;
min-width: 0;
.components-dropdown {
display: inline-flex;
}
.components-button {
min-width: 0;
padding: 0;
}
.mailpoet-email-campaign-name__link {
display: inline-flex;
height: fit-content;
margin-right: 10px;
margin-top: 10px;
}
}
// Document actions - Popup for editing email/campaign title
.mailpoet-email-editor-campaign-name__dropdown {
.components-popover__content {
min-width: 280px;
padding: 0;
}
.mailpoet-email-editor-email-title-edit {
padding: 16px;
}
}
.mailpoet-email-editor-save-button__dropdown {
.components-popover__content {
min-width: 280px;
}
}

View File

@@ -0,0 +1,3 @@
import './index.scss';
export * from './header';

View File

@@ -0,0 +1,137 @@
import { MenuGroup, MenuItem, DropdownMenu } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { displayShortcut } from '@wordpress/keycodes';
import { moreVertical } from '@wordpress/icons';
import { useEntityProp } from '@wordpress/core-data';
import { __, _x } from '@wordpress/i18n';
import { PreferenceToggleMenuItem } from '@wordpress/preferences';
import { useSelect, useDispatch } from '@wordpress/data';
import { storeName } from '../../store';
import { TrashModal } from './trash-modal';
// See:
// https://github.com/WordPress/gutenberg/blob/9601a33e30ba41bac98579c8d822af63dd961488/packages/edit-post/src/components/header/more-menu/index.js
// https://github.com/WordPress/gutenberg/blob/0ee78b1bbe9c6f3e6df99f3b967132fa12bef77d/packages/edit-site/src/components/header/more-menu/index.js
export function MoreMenu(): JSX.Element {
const [ showTrashModal, setShowTrashModal ] = useState( false );
const { urls, postId } = useSelect(
( select ) => ( {
urls: select( storeName ).getUrls(),
postId: select( storeName ).getEmailPostId(),
} ),
[]
);
const [ status, setStatus ] = useEntityProp(
'postType',
'mailpoet_email',
'status'
);
const { saveEditedEmail, updateEmailMailPoetProperty } =
useDispatch( storeName );
const goToListings = () => {
window.location.href = urls.listings;
};
return (
<>
<DropdownMenu
className="edit-site-more-menu"
popoverProps={ {
className: 'edit-site-more-menu__content',
} }
icon={ moreVertical }
label={ __( 'More', 'mailpoet' ) }
>
{ () => (
<>
<MenuGroup label={ _x( 'View', 'noun', 'mailpoet' ) }>
<PreferenceToggleMenuItem
scope="core"
name="fixedToolbar"
label={ __( 'Top toolbar', 'mailpoet' ) }
info={ __(
'Access all block and document tools in a single place',
'mailpoet'
) }
messageActivated={ __(
'Top toolbar activated',
'mailpoet'
) }
messageDeactivated={ __(
'Top toolbar deactivated',
'mailpoet'
) }
/>
<PreferenceToggleMenuItem
scope="core"
name="focusMode"
label={ __( 'Spotlight mode', 'mailpoet' ) }
info={ __(
'Focus at one block at a time',
'mailpoet'
) }
messageActivated={ __(
'Spotlight mode activated',
'mailpoet'
) }
messageDeactivated={ __(
'Spotlight mode deactivated',
'mailpoet'
) }
/>
<PreferenceToggleMenuItem
scope={ storeName }
name="fullscreenMode"
label={ __( 'Fullscreen mode', 'mailpoet' ) }
info={ __(
'Work without distraction',
'mailpoet'
) }
messageActivated={ __(
'Fullscreen mode activated',
'mailpoet'
) }
messageDeactivated={ __(
'Fullscreen mode deactivated',
'mailpoet'
) }
shortcut={ displayShortcut.secondary( 'f' ) }
/>
</MenuGroup>
<MenuGroup>
{ status === 'trash' ? (
<MenuItem
onClick={ async () => {
await setStatus( 'draft' );
await updateEmailMailPoetProperty(
'deleted_at',
''
);
await saveEditedEmail();
} }
>
{ __( 'Restore from trash', 'mailpoet' ) }
</MenuItem>
) : (
<MenuItem
onClick={ () => setShowTrashModal( true ) }
isDestructive
>
{ __( 'Move to trash', 'mailpoet' ) }
</MenuItem>
) }
</MenuGroup>
</>
) }
</DropdownMenu>
{ showTrashModal && (
<TrashModal
onClose={ () => setShowTrashModal( false ) }
onRemove={ goToListings }
postId={ postId }
/>
) }
</>
);
}

View File

@@ -0,0 +1,75 @@
import { useRef } from '@wordpress/element';
import { Button, Dropdown } from '@wordpress/components';
import {
// @ts-expect-error No types available for useEntitiesSavedStatesIsDirty
useEntitiesSavedStatesIsDirty,
// @ts-expect-error Our current version of packages doesn't have EntitiesSavedStates export
EntitiesSavedStates,
} from '@wordpress/editor';
import { __ } from '@wordpress/i18n';
import { useDispatch, useSelect } from '@wordpress/data';
import { check, cloud, Icon } from '@wordpress/icons';
import { storeName } from '../../store';
export function SaveButton() {
const { saveEditedEmail } = useDispatch( storeName );
const { dirtyEntityRecords } = useEntitiesSavedStatesIsDirty();
const { hasEdits, isEmpty, isSaving } = useSelect(
( select ) => ( {
hasEdits: select( storeName ).hasEdits(),
isEmpty: select( storeName ).isEmpty(),
isSaving: select( storeName ).isSaving(),
} ),
[]
);
const buttonRef = useRef( null );
const hasNonEmailEdits = dirtyEntityRecords.some(
( entity ) => entity.name !== 'mailpoet_email'
);
const isSaved = ! isEmpty && ! isSaving && ! hasEdits;
const isDisabled = isEmpty || isSaving || isSaved;
let label = __( 'Save Draft', 'mailpoet' );
if ( isSaved ) {
label = __( 'Saved', 'mailpoet' );
} else if ( isSaving ) {
label = __( 'Saving', 'mailpoet' );
}
return hasNonEmailEdits ? (
<div ref={ buttonRef }>
<Dropdown
popoverProps={ {
placement: 'bottom',
anchor: buttonRef.current,
} }
contentClassName="mailpoet-email-editor-save-button__dropdown"
renderToggle={ ( { onToggle } ) => (
<Button onClick={ onToggle } variant="tertiary">
{ hasEdits
? __( 'Save email & template', 'mailpoet' )
: __( 'Save template', 'mailpoet' ) }
</Button>
) }
renderContent={ ( { onToggle } ) => (
<EntitiesSavedStates close={ onToggle } />
) }
/>
</div>
) : (
<Button
variant="tertiary"
disabled={ isDisabled }
onClick={ saveEditedEmail }
>
{ isSaving && <Icon icon={ cloud } /> }
{ isSaved && <Icon icon={ check } /> }
{ label }
</Button>
);
}

View File

@@ -0,0 +1,54 @@
import { __ } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import {
store as editorStore,
// @ts-expect-error No types available for useEntitiesSavedStatesIsDirty
useEntitiesSavedStatesIsDirty,
} from '@wordpress/editor';
import { useEntityProp } from '@wordpress/core-data';
import { MailPoetEmailData, storeName } from '../../store';
import { useSelect } from '@wordpress/data';
import { useContentValidation } from '../../hooks';
export function SendButton() {
const [ mailpoetEmail ] = useEntityProp(
'postType',
'mailpoet_email',
'mailpoet_data'
);
const { isDirty } = useEntitiesSavedStatesIsDirty();
const { validateContent, isValid } = useContentValidation();
const { hasEmptyContent, isEmailSent, isEditingTemplate } = useSelect(
( select ) => ( {
hasEmptyContent: select( storeName ).hasEmptyContent(),
isEmailSent: select( storeName ).isEmailSent(),
isEditingTemplate:
select( editorStore ).getCurrentPostType() === 'wp_template',
} ),
[]
);
const isDisabled =
isEditingTemplate ||
hasEmptyContent ||
isEmailSent ||
isValid ||
isDirty;
const mailpoetEmailData: MailPoetEmailData = mailpoetEmail;
return (
<Button
variant="primary"
onClick={ () => {
if ( validateContent() ) {
window.location.href = `admin.php?page=mailpoet-newsletters#/send/${ mailpoetEmailData.id }`;
}
} }
disabled={ isDisabled }
>
{ __( 'Send', 'mailpoet' ) }
</Button>
);
}

View File

@@ -0,0 +1,81 @@
import { __ } from '@wordpress/i18n';
import { Button, Modal } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
export function TrashModal( {
onClose,
onRemove,
postId,
}: {
onClose: () => void;
onRemove: () => void;
postId: number;
} ) {
const { getLastEntityDeleteError } = useSelect( coreStore );
const { deleteEntityRecord } = useDispatch( coreStore );
const { createErrorNotice } = useDispatch( noticesStore );
const closeCallback = () => {
onClose();
};
const trashCallback = async () => {
const success = await deleteEntityRecord(
'postType',
'mailpoet_email',
postId as unknown as string,
{},
{ throwOnError: false }
);
if ( success ) {
onRemove();
} else {
const lastError = getLastEntityDeleteError(
'postType',
'mailpoet_email',
postId
);
// Already deleted.
if ( lastError?.code === 410 ) {
onRemove();
} else {
const errorMessage = lastError?.message
? ( lastError.message as string )
: __(
'An error occurred while moving the email to the trash.',
'mailpoet'
);
await createErrorNotice( errorMessage, {
type: 'snackbar',
isDismissible: true,
context: 'email-editor',
} );
}
}
};
return (
<Modal
className="mailpoet-move-to-trash-modal"
title={ __( 'Move to trash', 'mailpoet' ) }
onRequestClose={ closeCallback }
focusOnMount="firstContentElement"
>
<p>
{ __(
'Are you sure you want to move this email to trash?',
'mailpoet'
) }
</p>
<div className="mailpoet-send-preview-modal-footer">
<Button variant="tertiary" onClick={ closeCallback }>
{ __( 'Cancel', 'mailpoet' ) }
</Button>
<Button variant="primary" onClick={ trashCallback }>
{ __( 'Move to trash', 'mailpoet' ) }
</Button>
</div>
</Modal>
);
}
export default TrashModal;