diff --git a/includes/wizards/newspack/class-emails-section.php b/includes/wizards/newspack/class-emails-section.php index 148face613..fd5bd302c0 100644 --- a/includes/wizards/newspack/class-emails-section.php +++ b/includes/wizards/newspack/class-emails-section.php @@ -7,6 +7,9 @@ namespace Newspack\Wizards\Newspack; +use Newspack\Emails; +use Newspack\Reader_Activation; +use Newspack\Reader_Revenue_Emails; use Newspack\Wizards\Wizard_Section; use Newspack\WooCommerce_Emails; use WP_REST_Server; @@ -28,9 +31,6 @@ class Emails_Section extends Wizard_Section { * Register the endpoints needed for the wizard screens. */ public function register_rest_routes() { - if ( ! WooCommerce_Emails::is_active() ) { - return; - } register_rest_route( NEWSPACK_API_NAMESPACE, 'wizard/' . $this->wizard_slug . '/emails', @@ -40,23 +40,257 @@ public function register_rest_routes() { 'permission_callback' => [ $this, 'api_permissions_check' ], ] ); - register_rest_route( - NEWSPACK_API_NAMESPACE, - 'wizard/' . $this->wizard_slug . '/emails', - [ - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => [ __CLASS__, 'api_update_email_settings' ], - 'permission_callback' => [ $this, 'api_permissions_check' ], - 'args' => [ - 'enable_woocommerce_email_editor' => [ - 'type' => 'boolean', - 'required' => true, - 'sanitize_callback' => 'rest_sanitize_boolean', + if ( WooCommerce_Emails::is_active() ) { + register_rest_route( + NEWSPACK_API_NAMESPACE, + 'wizard/' . $this->wizard_slug . '/emails', + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => [ __CLASS__, 'api_update_email_settings' ], + 'permission_callback' => [ $this, 'api_permissions_check' ], + 'args' => [ + 'enable_woocommerce_email_editor' => [ + 'type' => 'boolean', + 'required' => true, + 'sanitize_callback' => 'rest_sanitize_boolean', + ], ], - ], + ] + ); + } + } - ] - ); + /** + * Get the unified email registry. + * + * Returns all known email entries keyed by a stable slug. Each entry + * includes metadata used by the Settings > Emails UI. + * + * @return array Registry entries keyed by slug. + */ + public static function get_email_registry(): array { + $registry = [ + 'verification' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-verification', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Reader verification', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader needs to verify their email address.', 'newspack-plugin' ), + ], + 'login-link' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-magic-link', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Magic login link', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader requests a magic login link.', 'newspack-plugin' ), + ], + 'login-otp' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-otp-authentication', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Login one-time password', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader logs in with a one-time password.', 'newspack-plugin' ), + ], + 'set-new-password' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-reset-password', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Password reset', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader requests a password reset.', 'newspack-plugin' ), + ], + 'receipt' => [ + 'source' => 'newspack', + 'newspack_type' => 'receipt', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Payment receipt', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent after a successful payment.', 'newspack-plugin' ), + ], + 'welcome' => [ + 'source' => 'newspack', + 'newspack_type' => 'welcome', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Welcome email', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to new supporters after their first payment.', 'newspack-plugin' ), + ], + 'cancellation' => [ + 'source' => 'newspack', + 'newspack_type' => 'cancellation', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Cancellation confirmation', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader cancels their subscription.', 'newspack-plugin' ), + ], + 'woo-renewal-reminder' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_renewal_invoice', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'recipient' => 'reader', + 'label' => __( 'Subscription renewal invoice', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to remind a customer that a renewal payment is due.', 'newspack-plugin' ), + ], + 'woo-payment-retry' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_payment_retry', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'recipient' => 'reader', + 'label' => __( 'Subscription payment retry', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a failed subscription payment is about to be retried.', 'newspack-plugin' ), + ], + 'woo-subscription-cancelled' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'cancelled_subscription', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'recipient' => 'reader', + 'label' => __( 'Subscription cancelled', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a subscription is cancelled.', 'newspack-plugin' ), + ], + 'woo-expired-subscription' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'expired_subscription', + 'recommended' => true, + 'plugin_dependency' => 'woocommerce-subscriptions', + 'recipient' => 'reader', + 'label' => __( 'Subscription expired', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a subscription reaches its expiration date.', 'newspack-plugin' ), + ], + 'woo-customer-new-account' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_new_account', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'New account', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a customer creates a new account.', 'newspack-plugin' ), + ], + 'woo-password-reset' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_reset_password', + 'recommended' => true, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Password reset (WooCommerce)', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a customer resets their password via WooCommerce.', 'newspack-plugin' ), + ], + 'delete-account' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-delete-account', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Account deletion', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a reader requests to delete their account.', 'newspack-plugin' ), + ], + 'change-email-notification' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-change-email-cancel', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Email change notification', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the old address when a reader changes their email.', 'newspack-plugin' ), + ], + 'change-email-confirmation' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-change-email', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Email change confirmation', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the new address to confirm an email change.', 'newspack-plugin' ), + ], + 'non-reader-login-reminder' => [ + 'source' => 'newspack', + 'newspack_type' => 'reader-activation-non-reader-user', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Non-reader login reminder', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when a non-reader WordPress user tries to log in as a reader.', 'newspack-plugin' ), + ], + 'group-subscription-invitation' => [ + 'source' => 'newspack', + 'newspack_type' => 'group-subscription-invite', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Group subscription invitation', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to invite a reader to join a group subscription.', 'newspack-plugin' ), + ], + 'woo-refund' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_refunded_order', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Order refund', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when an order is refunded.', 'newspack-plugin' ), + ], + // The following three WooCommerce emails are customer-facing but marked + // recommended=false because they are lower customization priority for + // subscription-focused publishers. + 'woo-processing-order' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_processing_order', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Order processing', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when an order payment is received and the order begins processing.', 'newspack-plugin' ), + ], + 'woo-completed-order' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_completed_order', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Order complete', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when an order is marked as complete.', 'newspack-plugin' ), + ], + 'woo-on-hold-order' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'customer_on_hold_order', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => __( 'Order on hold', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent when an order is placed on hold.', 'newspack-plugin' ), + ], + 'woo-new-order' => [ + 'source' => 'woocommerce', + 'woo_email_id' => 'new_order', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'admin', + 'label' => __( 'New order (admin)', 'newspack-plugin' ), + 'trigger_description' => __( 'Sent to the admin when a new order is placed.', 'newspack-plugin' ), + ], + ]; + + /** + * Filters the unified email registry. + * + * Allows external integration plugins to register additional email + * entries that appear in the Settings > Emails UI. + * + * @param array $registry Registry entries keyed by slug. + */ + return apply_filters( 'newspack_emails_registry', $registry ); } /** @@ -70,6 +304,69 @@ public static function api_get_email_settings(): array { $settings['admin_url'] = admin_url( 'admin.php?page=wc-settings&tab=email' ); $settings['enable_woocommerce_email_editor'] = 'yes' === WooCommerce_Emails::is_enabled(); } + + // Build newspack_emails from the Emails system, enriched with registry data. + $config_names = []; + if ( ! Reader_Activation::is_enabled() ) { + $config_names = array_values( Reader_Revenue_Emails::EMAIL_TYPES ); + } + $emails = Emails::get_emails( $config_names, false ); + + // Build a lookup from newspack_type => registry entry. + $registry = self::get_email_registry(); + $registry_lookup = []; + foreach ( $registry as $slug => $entry ) { + if ( isset( $entry['newspack_type'] ) ) { + $registry_lookup[ $entry['newspack_type'] ] = array_merge( $entry, [ 'registry_slug' => $slug ] ); + } + } + + $newspack_emails = []; + foreach ( $emails as $type => $email ) { + if ( isset( $registry_lookup[ $type ] ) ) { + $match = $registry_lookup[ $type ]; + $email['label'] = $match['label']; + $email['recommended'] = $match['recommended']; + $email['trigger_description'] = $match['trigger_description']; + $email['registry_slug'] = $match['registry_slug']; + $email['recipient'] = $match['recipient']; + $email['source'] = $match['source']; + } else { + $email['recommended'] = false; + $email['trigger_description'] = ''; + $email['registry_slug'] = ''; + $email['recipient'] = 'reader'; + $email['source'] = 'newspack'; // Default; WooCommerce emails always match above. + } + $newspack_emails[] = $email; + } + + // Sort: reader-revenue first, reader-activation second, woocommerce last. + // Within each group, preserve registry insertion order via a stable tiebreaker. + // Category strings originate from Reader_Revenue_Emails::add_email_configs(), + // Reader_Activation_Emails::add_email_configs(), and WooCommerce_Emails. + $category_order = [ + 'reader-revenue' => 0, + 'reader-activation' => 1, + ]; + $slug_order = array_flip( array_keys( $registry ) ); + usort( + $newspack_emails, + function ( $a, $b ) use ( $category_order, $slug_order ) { + $order_a = $category_order[ $a['category'] ?? '' ] ?? 2; + $order_b = $category_order[ $b['category'] ?? '' ] ?? 2; + if ( $order_a !== $order_b ) { + return $order_a - $order_b; + } + $idx_a = $slug_order[ $a['registry_slug'] ?? '' ] ?? PHP_INT_MAX; + $idx_b = $slug_order[ $b['registry_slug'] ?? '' ] ?? PHP_INT_MAX; + return $idx_a - $idx_b; + } + ); + + $settings['newspack_emails'] = $newspack_emails; + $settings['post_type'] = Emails::POST_TYPE; + return $settings; } diff --git a/includes/wizards/newspack/class-newspack-settings.php b/includes/wizards/newspack/class-newspack-settings.php index b8886a3d24..4e7b6e1180 100644 --- a/includes/wizards/newspack/class-newspack-settings.php +++ b/includes/wizards/newspack/class-newspack-settings.php @@ -87,7 +87,6 @@ public function get_local_data() { 'dependencies' => [ 'newspackNewsletters' => is_plugin_active( 'newspack-newsletters/newspack-newsletters.php' ), ], - 'all' => Emails::get_emails( Reader_Activation::is_enabled() ? [] : array_values( Reader_Revenue_Emails::EMAIL_TYPES ), false ), 'postType' => Emails::POST_TYPE, 'isEmailEnhancementsActive' => WooCommerce_Emails::is_active(), ], diff --git a/packages/components/src/badge/style.scss b/packages/components/src/badge/style.scss index 72615a38c3..241d4faad1 100644 --- a/packages/components/src/badge/style.scss +++ b/packages/components/src/badge/style.scss @@ -16,7 +16,7 @@ $badge-colors: ( background-color: color-mix(in srgb, wp-colors.$white 90%, var(--base-color)); border-radius: 2px; color: color-mix(in srgb, wp-colors.$black 50%, var(--base-color)); - display: block; + display: inline-block; flex: 0 0 auto; font-size: 12px; line-height: 1.5; diff --git a/src/wizards/newspack/views/settings/emails/emails.scss b/src/wizards/newspack/views/settings/emails/emails.scss new file mode 100644 index 0000000000..6719a3d3e7 --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/emails.scss @@ -0,0 +1,58 @@ +@use "~@wordpress/base-styles/colors" as wp-colors; + +// Full-width override for the emails tab. +// The parent `.newspack-wizard__sections` constrains width via max-width. +// Break out of that for the DataViews table. +.newspack-emails-tab.newspack-wizard__sections { + max-width: 100%; +} + +// DataViews search/filter/icon alignment. +.newspack-emails-tab { + .dataviews__view-actions { + display: flex; + align-items: center; + margin-bottom: 16px; + } + + .dataviews__search { + display: flex; + align-items: flex-start; + gap: 8px; + } +} + +// Preview placeholder (grid view media field). +.newspack-emails__preview-placeholder { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + width: 100%; + background: wp-colors.$gray-100; + border-radius: 4px; + color: wp-colors.$gray-700; +} + +// Clickable name link in grid/table view. +.newspack-emails__name-link { + color: inherit; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } +} + +// Trigger description sub-text under the title. +// Two-line clamp keeps grid cards uniform while still showing enough context. +.newspack-emails__trigger-description { + color: wp-colors.$gray-900; + font-size: 12px; + margin-top: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/src/wizards/newspack/views/settings/emails/emails.test.js b/src/wizards/newspack/views/settings/emails/emails.test.js new file mode 100644 index 0000000000..d9cf87108b --- /dev/null +++ b/src/wizards/newspack/views/settings/emails/emails.test.js @@ -0,0 +1,346 @@ +// @jest-environment jsdom + +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; + +jest.mock( './emails.scss', () => ( {} ) ); + +// Use mock-prefixed names so Jest's hoisted jest.mock can close over them. +const mockWizardApiFetch = jest.fn(); +const mockResetError = jest.fn(); +let mockErrorMessage = null; + +jest.mock( '../../../../hooks/use-wizard-api-fetch', () => ( { + useWizardApiFetch: () => ( { + wizardApiFetch: ( ...args ) => mockWizardApiFetch( ...args ), + isFetching: false, + errorMessage: mockErrorMessage, + resetError: ( ...args ) => mockResetError( ...args ), + } ), +} ) ); + +jest.mock( '@wordpress/icons', () => ( { + Icon: ( { icon } ) => { icon }, + envelope: 'envelope', +} ) ); + +jest.mock( '@wordpress/dataviews', () => ( { + filterSortAndPaginate: data => ( { + data, + paginationInfo: { totalItems: data.length, totalPages: 1 }, + } ), +} ) ); + +// Use mock-prefixed name so Jest's hoisted jest.mock can close over it. +let mockCapturedActions = []; + +jest.mock( '../../../../../../packages/components/src', () => { + function renderField( field, item ) { + if ( field.render ) { + return field.render( { item } ); + } + if ( field.getValue ) { + return field.getValue( { item } ); + } + return null; + } + return { + Badge: ( { text } ) => { text }, + DataViews: ( { data, fields, actions } ) => { + mockCapturedActions = actions || []; + return ( + + + { data.map( ( item, i ) => ( + + { fields.map( field => ( + + ) ) } + + ) ) } + +
{ renderField( field, item ) }
+ ); + }, + Card: ( { children } ) =>
{ children }
, + Notice: ( { noticeText } ) =>
{ noticeText }
, + utils: { + confirmAction: jest.fn( () => true ), + }, + }; +} ); + +jest.mock( + '../../../../wizards-plugin-card', + () => + function MockPluginCard( props ) { + return
{ props.slug }
; + } +); + +const mockEmails = [ + { + label: 'Payment receipt', + post_id: 1, + edit_link: '/edit/1', + status: 'publish', + type: 'receipt', + category: 'reader-revenue', + trigger_description: 'Sent after a successful payment.', + registry_slug: 'receipt', + recipient: 'reader', + source: 'newspack', + }, + { + label: 'Cancellation confirmation', + post_id: 2, + edit_link: '/edit/2', + status: 'publish', + type: 'cancellation', + category: 'reader-revenue', + trigger_description: 'Sent when a reader cancels their subscription.', + registry_slug: 'cancellation', + recipient: 'reader', + source: 'newspack', + }, + { + label: 'Reader verification', + post_id: 3, + edit_link: '/edit/3', + status: 'publish', + type: 'reader-activation-verification', + category: 'reader-activation', + trigger_description: 'Sent when a reader needs to verify their email address.', + registry_slug: 'verification', + recipient: 'reader', + source: 'newspack', + }, + { + label: 'Account deletion', + post_id: 4, + edit_link: '/edit/4', + status: 'draft', + type: 'reader-activation-delete-account', + category: 'reader-activation', + trigger_description: 'Sent when a reader requests to delete their account.', + registry_slug: 'delete-account', + recipient: 'reader', + source: 'newspack', + }, + { + label: 'New order (admin)', + post_id: 5, + edit_link: '/edit/5', + status: 'publish', + type: 'new_order', + category: 'woocommerce', + trigger_description: 'Sent to the admin when a new order is placed.', + registry_slug: 'woo-new-order', + recipient: 'admin', + source: 'woocommerce', + }, + { + label: 'Order on hold', + post_id: 6, + edit_link: '/edit/6', + status: 'draft', + type: 'customer_on_hold_order', + category: 'woocommerce', + trigger_description: 'Sent when an order is placed on hold.', + registry_slug: 'woo-on-hold-order', + recipient: 'reader', + source: 'woocommerce', + }, +]; + +describe( 'Emails', () => { + beforeEach( () => { + jest.clearAllMocks(); + mockErrorMessage = null; + window.newspackSettings = { + emails: { + sections: { + emails: { + dependencies: { + newspackNewsletters: true, + }, + postType: 'newspack_rr_email', + isEmailEnhancementsActive: false, + }, + }, + }, + }; + mockWizardApiFetch.mockImplementation( ( opts, callbacks ) => { + if ( opts.path === '/newspack/v1/wizard/newspack-settings/emails' ) { + callbacks?.onSuccess?.( { + newspack_emails: mockEmails, + post_type: 'newspack_rr_email', + } ); + } + return Promise.resolve(); + } ); + } ); + + it( 'renders all emails in a single view', async () => { + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + expect( screen.getByText( 'Payment receipt' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Cancellation confirmation' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Reader verification' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Account deletion' ) ).toBeInTheDocument(); + expect( screen.getByText( 'New order (admin)' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Order on hold' ) ).toBeInTheDocument(); + } ); + } ); + + it( 'renders Recipient column with correct values', async () => { + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + const readerCells = screen.getAllByText( 'Reader' ); + expect( readerCells.length ).toBeGreaterThanOrEqual( 4 ); + expect( screen.getByText( 'Admin' ) ).toBeInTheDocument(); + } ); + } ); + + it( 'renders status as Enabled / Disabled', async () => { + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + const enabledCells = screen.getAllByText( 'Enabled' ); + expect( enabledCells.length ).toBeGreaterThanOrEqual( 3 ); + const disabledCells = screen.getAllByText( 'Disabled' ); + expect( disabledCells.length ).toBeGreaterThanOrEqual( 2 ); + } ); + } ); + + it( 'deactivate action calls wizardApiFetch with draft status', async () => { + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument(); + } ); + + const deactivate = mockCapturedActions.find( a => a.id === 'deactivate' ); + deactivate.callback( [ mockEmails[ 0 ] ] ); + + expect( mockWizardApiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + path: '/wp/v2/newspack_rr_email/1', + method: 'POST', + data: { status: 'draft' }, + } ), + expect.objectContaining( { + onSuccess: expect.any( Function ), + } ) + ); + } ); + + it( 'displays error notice when hook reports an error', async () => { + mockErrorMessage = 'Something went wrong'; + + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'notice' ) ).toBeInTheDocument(); + expect( screen.getByTestId( 'notice' ) ).toHaveTextContent( 'Something went wrong' ); + } ); + } ); + + it( 'activate action calls wizardApiFetch with publish status', async () => { + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument(); + } ); + + const activate = mockCapturedActions.find( a => a.id === 'activate' ); + // mockEmails[3] (Account deletion) is newspack + draft — eligible for activate. + activate.callback( [ mockEmails[ 3 ] ] ); + + expect( mockWizardApiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + path: '/wp/v2/newspack_rr_email/4', + method: 'POST', + data: { status: 'publish' }, + } ), + expect.objectContaining( { + onSuccess: expect.any( Function ), + } ) + ); + } ); + + it( 'deactivate/activate are not eligible for reader-activation or woocommerce emails', async () => { + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument(); + } ); + + const deactivate = mockCapturedActions.find( a => a.id === 'deactivate' ); + const activate = mockCapturedActions.find( a => a.id === 'activate' ); + + // Reader-activation emails cannot be toggled. + expect( deactivate.isEligible( mockEmails[ 2 ] ) ).toBe( false ); + // WooCommerce emails cannot be toggled. + expect( deactivate.isEligible( mockEmails[ 4 ] ) ).toBe( false ); + // Newspack reader-revenue email can be deactivated. + expect( deactivate.isEligible( mockEmails[ 0 ] ) ).toBe( true ); + // Draft reader-activation email cannot be activated. + expect( activate.isEligible( mockEmails[ 3 ] ) ).toBe( false ); + // Draft WooCommerce email cannot be activated. + expect( activate.isEligible( mockEmails[ 5 ] ) ).toBe( false ); + } ); + + it( 'reset action calls wizardApiFetch with DELETE after confirmation', async () => { + const { utils } = require( '../../../../../../packages/components/src' ); + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument(); + } ); + + const reset = mockCapturedActions.find( a => a.id === 'reset' ); + reset.callback( [ mockEmails[ 0 ] ] ); + + expect( utils.confirmAction ).toHaveBeenCalled(); + + expect( mockWizardApiFetch ).toHaveBeenCalledWith( + expect.objectContaining( { + path: '/newspack/v1/wizard/newspack-audience-donations/emails/1', + method: 'DELETE', + } ), + expect.objectContaining( { + onSuccess: expect.any( Function ), + } ) + ); + } ); + + it( 'reset is eligible for newspack-source emails with a registry_slug', async () => { + const Emails = require( './emails' ).default; + render( ); + + await waitFor( () => { + expect( screen.getByTestId( 'dataviews' ) ).toBeInTheDocument(); + } ); + + const reset = mockCapturedActions.find( a => a.id === 'reset' ); + // Newspack email with registry_slug — eligible. + expect( reset.isEligible( mockEmails[ 0 ] ) ).toBe( true ); + // WooCommerce email — not eligible. + expect( reset.isEligible( mockEmails[ 4 ] ) ).toBe( false ); + // Email without registry_slug — not eligible. + expect( reset.isEligible( { ...mockEmails[ 0 ], registry_slug: '' } ) ).toBe( false ); + } ); +} ); diff --git a/src/wizards/newspack/views/settings/emails/emails.tsx b/src/wizards/newspack/views/settings/emails/emails.tsx index 144781df48..4ec8aaaded 100644 --- a/src/wizards/newspack/views/settings/emails/emails.tsx +++ b/src/wizards/newspack/views/settings/emails/emails.tsx @@ -6,77 +6,238 @@ * WordPress dependencies. */ import { __ } from '@wordpress/i18n'; -import { useState, Fragment } from '@wordpress/element'; +import { useState, useEffect, useMemo, Fragment } from '@wordpress/element'; +import { filterSortAndPaginate } from '@wordpress/dataviews'; +import type { Action, Field, View } from '@wordpress/dataviews'; +import { Icon, envelope } from '@wordpress/icons'; /** * Internal dependencies. */ -import { Notice, utils } from '../../../../../../packages/components/src'; -import { useWizardApiFetch } from '../../../../hooks/use-wizard-api-fetch'; -import WizardsActionCard from '../../../../wizards-action-card'; +import { Badge, DataViews, Notice, utils } from '../../../../../../packages/components/src'; import WizardsPluginCard from '../../../../wizards-plugin-card'; +import { useWizardApiFetch } from '../../../../hooks/use-wizard-api-fetch'; +import './emails.scss'; + +interface EmailItem { + label: string; + post_id: number; + edit_link: string; + status: string; + type: string; + category: string; + trigger_description: string; + registry_slug: string; + recipient: 'reader' | 'admin'; + source: 'newspack' | 'woocommerce'; +} + +interface EmailSettings { + newspack_emails: EmailItem[]; + post_type: string; + admin_url?: string; + enable_woocommerce_email_editor?: boolean; +} + +const DEFAULT_VIEW: View = { + type: 'grid', + page: 1, + perPage: 50, + search: '', + fields: [ 'recipient', 'status' ], + filters: [], + layout: {}, + titleField: 'name', + descriptionField: 'trigger_description', + mediaField: 'preview', +}; + +const PageHeading = () =>

{ __( 'Emails', 'newspack-plugin' ) }

; const Emails = () => { const emailSections = window.newspackSettings.emails.sections; - const postType = emailSections.emails.postType; + const [ pluginsReady, setPluginsReady ] = useState( Boolean( emailSections.emails.dependencies.newspackNewsletters ) ); - const [ pluginsReady, setPluginsReady ] = useState( emailSections.emails.dependencies.newspackNewsletters ); + const [ data, setData ] = useState< EmailItem[] >( [] ); + const [ postType, setPostType ] = useState< string >( emailSections.emails.postType ); + const [ view, setView ] = useState< View >( DEFAULT_VIEW ); const { wizardApiFetch, isFetching, errorMessage, resetError } = useWizardApiFetch( 'newspack-settings/emails' ); - const [ emails, setEmails ] = useState( Object.values( emailSections.emails.all ) ); + const fetchData = () => { + resetError(); + wizardApiFetch< EmailSettings >( + { + path: '/newspack/v1/wizard/newspack-settings/emails', + isCached: false, + }, + { + onSuccess( result: EmailSettings ) { + setData( result.newspack_emails || [] ); + if ( result.post_type ) { + setPostType( result.post_type ); + } + }, + } + ); + }; + + useEffect( fetchData, [] ); - const updateStatus = ( postId: number, status: string ) => { + const updateStatus = ( postId: number, nextStatus: string ) => { + resetError(); wizardApiFetch( { path: `/wp/v2/${ postType }/${ postId }`, method: 'POST', - data: { status }, + data: { status: nextStatus }, }, { - onStart() { - resetError(); - }, - onSuccess() { - setEmails( - emails.map( email => { - if ( email.post_id === postId ) { - return { ...email, status }; - } - return email; - } ) - ); - }, + onSuccess: () => fetchData(), } ); }; const resetEmail = ( postId: number ) => { + resetError(); + // @todo NPPD-1532 Move reset handler to class-emails-section.php so it + // lives under wizard/newspack-settings/emails/{id} instead of reaching + // into the donations wizard namespace. wizardApiFetch( { path: `/newspack/v1/wizard/newspack-audience-donations/emails/${ postId }`, method: 'DELETE', }, { - onSuccess( result ) { - window.newspackSettings.emails.sections.emails.all = result; - setEmails( Object.values( result ) ); - }, + onSuccess: () => fetchData(), } ); }; + const fields: Field< EmailItem >[] = useMemo( + () => [ + { + id: 'preview', + label: __( 'Preview', 'newspack-plugin' ), + type: 'media', + enableSorting: false, + enableHiding: true, + // @todo NPPD-1525 Replace with component. + render: ( { item }: { item: EmailItem } ) => ( + + + + ), + }, + { + id: 'name', + label: __( 'Email', 'newspack-plugin' ), + enableGlobalSearch: true, + getValue: ( { item }: { item: EmailItem } ) => item.label, + render: ( { item }: { item: EmailItem } ) => ( + + { item.label } + + ), + }, + { + id: 'trigger_description', + label: __( 'Description', 'newspack-plugin' ), + enableGlobalSearch: true, + getValue: ( { item }: { item: EmailItem } ) => item.trigger_description, + render: ( { item }: { item: EmailItem } ) => ( + { item.trigger_description } + ), + enableHiding: false, + enableSorting: false, + }, + { + id: 'recipient', + label: __( 'Recipient', 'newspack-plugin' ), + enableGlobalSearch: true, + getValue: ( { item }: { item: EmailItem } ) => + item.recipient === 'admin' ? __( 'Admin', 'newspack-plugin' ) : __( 'Reader', 'newspack-plugin' ), + render: ( { item }: { item: EmailItem } ) => ( + { item.recipient === 'admin' ? __( 'Admin', 'newspack-plugin' ) : __( 'Reader', 'newspack-plugin' ) } + ), + }, + { + id: 'status', + label: __( 'Status', 'newspack-plugin' ), + getValue: ( { item }: { item: EmailItem } ) => item.status, + render: ( { item }: { item: EmailItem } ) => { + const isEnabled = item.status === 'publish'; + return ( + + ); + }, + elements: [ + { value: 'publish', label: __( 'Enabled', 'newspack-plugin' ) }, + { value: 'draft', label: __( 'Disabled', 'newspack-plugin' ) }, + ], + filterBy: { isPrimary: false, operators: [ 'is' ] }, + }, + ], + [] + ); + + const actions: Action< EmailItem >[] = [ + { + id: 'edit', + label: __( 'Edit', 'newspack-plugin' ), + callback: ( items: EmailItem[] ) => { + window.location.href = items[ 0 ].edit_link; + }, + }, + { + id: 'deactivate', + label: __( 'Deactivate', 'newspack-plugin' ), + isEligible: ( item: EmailItem ) => item.source !== 'woocommerce' && item.category !== 'reader-activation' && item.status === 'publish', + callback: ( items: EmailItem[] ) => { + updateStatus( items[ 0 ].post_id, 'draft' ); + }, + }, + { + id: 'activate', + label: __( 'Activate', 'newspack-plugin' ), + isEligible: ( item: EmailItem ) => item.source !== 'woocommerce' && item.category !== 'reader-activation' && item.status !== 'publish', + callback: ( items: EmailItem[] ) => { + updateStatus( items[ 0 ].post_id, 'publish' ); + }, + }, + { + id: 'reset', + label: __( 'Reset', 'newspack-plugin' ), + isDestructive: true, + isEligible: ( item: EmailItem ) => item.source !== 'woocommerce' && Boolean( item.registry_slug ), + callback: ( items: EmailItem[] ) => { + if ( utils.confirmAction( __( 'Are you sure you want to reset the contents of this email?', 'newspack-plugin' ) ) ) { + resetEmail( items[ 0 ].post_id ); + } + }, + }, + ]; + + const { data: processedData, paginationInfo } = useMemo( () => filterSortAndPaginate( data, view, fields ), [ data, view, fields ] ); + if ( false === pluginsReady ) { return ( - - { __( - 'Newspack uses Newspack Newsletters to handle editing email-type content. Please activate this plugin to proceed.', - 'newspack-plugin' - ) } -
- { __( 'Until this feature is configured, default receipts will be used.', 'newspack-plugin' ) } -
+ + { return ( - { emails.map( email => { - const isActive = email.status === 'publish'; - const isAudience = email.category === 'reader-activation'; - let notification = __( 'This email is not active.', 'newspack-plugin' ); - if ( email.type === 'receipt' ) { - notification = __( 'This email is not active. The default receipt will be used.', 'newspack-plugin' ); - } - if ( email.type === 'welcome' ) { - notification = __( 'This email is not active. The receipt template will be used if active.', 'newspack-plugin' ); - } - return ( - { - if ( utils.confirmAction( __( 'Are you sure you want to reset the contents of this email?', 'newspack-plugin' ) ) ) { - resetEmail( email.post_id ); - } - } } - secondaryDestructive={ true } - { ...( isAudience - ? {} - : { - toggleChecked: isActive, - toggleOnChange: value => updateStatus( email.post_id, value ? 'publish' : 'draft' ), - } ) } - { ...( isActive - ? {} - : { - notification, - notificationLevel: 'info', - } ) } - > - { errorMessage && } - - ); - } ) } + + { errorMessage && } + String( item.post_id ) } + search + /> ); }; diff --git a/src/wizards/newspack/views/settings/emails/index.tsx b/src/wizards/newspack/views/settings/emails/index.tsx index aea7f446a0..6f6d6cdaec 100644 --- a/src/wizards/newspack/views/settings/emails/index.tsx +++ b/src/wizards/newspack/views/settings/emails/index.tsx @@ -2,11 +2,6 @@ * Newspack > Settings > Emails */ -/** - * WordPress dependencies. - */ -import { __ } from '@wordpress/i18n'; - /** * Internal dependencies. */ @@ -19,7 +14,7 @@ const { emails } = window.newspackSettings; function Emails() { return ( - + diff --git a/tests/unit-tests/emails-section.php b/tests/unit-tests/emails-section.php new file mode 100644 index 0000000000..256219a44a --- /dev/null +++ b/tests/unit-tests/emails-section.php @@ -0,0 +1,253 @@ + $entry ) { + foreach ( $required_keys as $key ) { + $this->assertArrayHasKey( $key, $entry, "Entry '$slug' is missing required key '$key'." ); + } + } + } + + /** + * Test all registry entries have a valid source value. + */ + public function test_registry_entries_have_valid_source() { + $registry = Emails_Section::get_email_registry(); + foreach ( $registry as $slug => $entry ) { + $this->assertContains( $entry['source'], [ 'newspack', 'woocommerce' ], "Entry '$slug' has an invalid source value." ); + } + } + + /** + * Test all registry entries have a valid recipient value. + */ + public function test_registry_entries_have_valid_recipient() { + $registry = Emails_Section::get_email_registry(); + foreach ( $registry as $slug => $entry ) { + $this->assertContains( $entry['recipient'], [ 'reader', 'admin' ], "Entry '$slug' has an invalid recipient value." ); + } + } + + /** + * Test all registry entries have non-empty labels and trigger descriptions. + */ + public function test_registry_entries_have_labels_and_triggers() { + $registry = Emails_Section::get_email_registry(); + foreach ( $registry as $slug => $entry ) { + $this->assertNotEmpty( $entry['label'], "Entry '$slug' is missing a label." ); + $this->assertNotEmpty( $entry['trigger_description'], "Entry '$slug' is missing a trigger_description." ); + } + } + + /** + * Test recommended is always a boolean. + */ + public function test_registry_recommended_is_boolean() { + $registry = Emails_Section::get_email_registry(); + foreach ( $registry as $slug => $entry ) { + $this->assertIsBool( $entry['recommended'], "Entry '$slug' has a non-boolean recommended value." ); + } + } + + /** + * Test each entry has either newspack_type or woo_email_id, never both. + */ + public function test_registry_entries_have_exclusive_type_keys() { + $registry = Emails_Section::get_email_registry(); + foreach ( $registry as $slug => $entry ) { + $has_newspack = isset( $entry['newspack_type'] ); + $has_woo = isset( $entry['woo_email_id'] ); + $this->assertTrue( $has_newspack || $has_woo, "Entry '$slug' has neither newspack_type nor woo_email_id." ); + $this->assertFalse( $has_newspack && $has_woo, "Entry '$slug' has both newspack_type and woo_email_id." ); + } + } + + /** + * Test newspack-source entries have newspack_type and woocommerce-source entries have woo_email_id. + */ + public function test_registry_source_matches_type_key() { + $registry = Emails_Section::get_email_registry(); + foreach ( $registry as $slug => $entry ) { + if ( 'newspack' === $entry['source'] ) { + $this->assertArrayHasKey( 'newspack_type', $entry, "Newspack-source entry '$slug' is missing newspack_type." ); + $this->assertNotEmpty( $entry['newspack_type'], "Newspack-source entry '$slug' has empty newspack_type." ); + } + if ( 'woocommerce' === $entry['source'] ) { + $this->assertArrayHasKey( 'woo_email_id', $entry, "WooCommerce-source entry '$slug' is missing woo_email_id." ); + $this->assertNotEmpty( $entry['woo_email_id'], "WooCommerce-source entry '$slug' has empty woo_email_id." ); + } + } + } + + /** + * Test no duplicate newspack_type or woo_email_id values across entries. + */ + public function test_registry_no_duplicate_type_values() { + $registry = Emails_Section::get_email_registry(); + $newspack_types = []; + $woo_ids = []; + + foreach ( $registry as $slug => $entry ) { + if ( isset( $entry['newspack_type'] ) ) { + $this->assertNotContains( $entry['newspack_type'], $newspack_types, "Duplicate newspack_type '{$entry['newspack_type']}' in entry '$slug'." ); + $newspack_types[] = $entry['newspack_type']; + } + if ( isset( $entry['woo_email_id'] ) ) { + $this->assertNotContains( $entry['woo_email_id'], $woo_ids, "Duplicate woo_email_id '{$entry['woo_email_id']}' in entry '$slug'." ); + $woo_ids[] = $entry['woo_email_id']; + } + } + } + + /** + * Test api_get_email_settings returns the expected response shape. + */ + public function test_api_get_email_settings_response_shape() { + $result = Emails_Section::api_get_email_settings(); + + $this->assertIsArray( $result ); + $this->assertArrayHasKey( 'newspack_emails', $result ); + $this->assertArrayHasKey( 'post_type', $result ); + $this->assertIsArray( $result['newspack_emails'] ); + + if ( class_exists( 'WooCommerce' ) ) { + $this->assertArrayHasKey( 'admin_url', $result ); + $this->assertArrayHasKey( 'enable_woocommerce_email_editor', $result ); + } + + // Verify enriched fields on each Newspack email that has a registry_slug. + $enriched_keys = [ 'label', 'recommended', 'trigger_description', 'registry_slug', 'recipient', 'source' ]; + $enriched_count = 0; + foreach ( $result['newspack_emails'] as $email ) { + if ( empty( $email['registry_slug'] ) ) { + // Fallback branch: verify defaults are set. + $this->assertArrayHasKey( 'recommended', $email, 'Fallback email is missing recommended.' ); + $this->assertFalse( $email['recommended'], 'Fallback email should have recommended=false.' ); + $this->assertArrayHasKey( 'source', $email, 'Fallback email is missing source.' ); + continue; + } + ++$enriched_count; + foreach ( $enriched_keys as $key ) { + $this->assertArrayHasKey( $key, $email, "Email '{$email['label']}' is missing enriched field '$key'." ); + } + } + $this->assertGreaterThan( 0, $enriched_count, 'Expected at least one enriched email in the response, but found none.' ); + } + + /** + * Test sort order: reader-revenue first, reader-activation second, other categories last. + */ + public function test_api_get_email_settings_sort_order() { + $result = Emails_Section::api_get_email_settings(); + $categories = array_column( $result['newspack_emails'], 'category' ); + + // Build the expected group order: reader-revenue → reader-activation → everything else. + $last_group = -1; + $group_map = [ + 'reader-revenue' => 0, + 'reader-activation' => 1, + ]; + foreach ( $categories as $i => $cat ) { + $group = $group_map[ $cat ] ?? 2; + $this->assertGreaterThanOrEqual( $last_group, $group, "Email at index $i (category '$cat') is out of sort order." ); + $last_group = $group; + } + } + + /** + * Test admin-recipient emails are correctly classified. + */ + public function test_admin_recipient_emails() { + $registry = Emails_Section::get_email_registry(); + $admin_slugs = array_keys( + array_filter( + $registry, + function ( $entry ) { + return 'admin' === $entry['recipient']; + } + ) + ); + $this->assertContains( 'woo-new-order', $admin_slugs, 'new_order is an admin email.' ); + // woo-subscription-cancelled is reader-facing (Newspack notifies the reader); + // the separate WC admin notification is handled by WooCommerce core. + $this->assertNotContains( 'woo-subscription-cancelled', $admin_slugs ); + } + + /** + * Test that the newspack_emails_registry filter can add entries. + */ + public function test_emails_registry_filter() { + $fake_entry = [ + 'source' => 'newspack', + 'newspack_type' => 'test-filter-email', + 'recommended' => false, + 'plugin_dependency' => null, + 'recipient' => 'reader', + 'label' => 'Test filter email', + 'trigger_description' => 'Added via filter.', + ]; + + $callback = function ( $registry ) use ( $fake_entry ) { + $registry['test-filter-email'] = $fake_entry; + return $registry; + }; + + add_filter( 'newspack_emails_registry', $callback ); + $registry = Emails_Section::get_email_registry(); + remove_filter( 'newspack_emails_registry', $callback ); + + $this->assertArrayHasKey( 'test-filter-email', $registry, 'Filter-added entry should be present in the registry.' ); + $this->assertSame( $fake_entry, $registry['test-filter-email'] ); + } + + /** + * Test registry insertion order within source groups. + * + * The UI relies on registry order to determine display order within + * each category group (reader-revenue, reader-activation, woocommerce). + */ + public function test_registry_order_within_groups() { + $slugs = array_keys( Emails_Section::get_email_registry() ); + + // Reader-revenue group: receipt → welcome → cancellation. + $this->assertLessThan( + array_search( 'welcome', $slugs, true ), + array_search( 'receipt', $slugs, true ), + 'receipt should appear before welcome.' + ); + $this->assertLessThan( + array_search( 'cancellation', $slugs, true ), + array_search( 'welcome', $slugs, true ), + 'welcome should appear before cancellation.' + ); + + // Reader-activation group: verification → login-link → set-new-password. + $this->assertLessThan( + array_search( 'login-link', $slugs, true ), + array_search( 'verification', $slugs, true ), + 'verification should appear before login-link.' + ); + $this->assertLessThan( + array_search( 'set-new-password', $slugs, true ), + array_search( 'login-link', $slugs, true ), + 'login-link should appear before set-new-password.' + ); + } +}