Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions plugins/newspack-plugin/includes/class-wizards.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ public static function init_wizards() {
[
'sections' => [
'custom-events' => 'Newspack\Wizards\Newspack\Custom_Events_Section',
'emails' => 'Newspack\Wizards\Newspack\Emails_Section',
'social-pixels' => 'Newspack\Wizards\Newspack\Pixels_Section',
'recirculation' => 'Newspack\Wizards\Newspack\Recirculation_Section',
'syndication' => 'Newspack\Wizards\Newspack\Syndication_Section',
Expand All @@ -64,7 +63,13 @@ public static function init_wizards() {
),
'advertising-display-ads' => new Advertising_Display_Ads(),
'advertising-sponsors' => new Advertising_Sponsors(),
'audience' => new Audience_Wizard(),
'audience' => new Audience_Wizard(
[
'sections' => [
'emails' => 'Newspack\Wizards\Newspack\Emails_Section',
],
]
),
'audience-campaigns' => new Audience_Campaigns(),
'audience-content-gates' => new Audience_Content_Gates(),
'audience-donations' => new Audience_Donations(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,16 @@ class Audience_Wizard extends Wizard {

/**
* Audience Configuration Constructor.
*
* @param array $args Optional. Wizard arguments — forwarded to the
* parent so `sections` can be loaded via
* `Wizard::load_wizard_sections()`. Used by the
* Emails section, which hosts under Audience now
* (NPPD-1538) but keeps its REST_BASE pinned to
* `newspack-settings` for API stability.
*/
public function __construct() {
parent::__construct();
public function __construct( $args = [] ) {
parent::__construct( $args );
add_action( 'rest_api_init', [ $this, 'register_api_endpoints' ] );

// Determine active menu items.
Expand Down Expand Up @@ -114,6 +121,17 @@ public function enqueue_scripts_and_styles() {
'has_metering' => Content_Gate::is_metering_enabled( Memberships::GATE_CPT ),
];

// SSR-bootstrap the emails tab so DataViews renders on first paint
// instead of waiting for the mount-time XHR. Same shape as the API
// response so the React seed and the post-fetch state line up.
$data['emails'] = [
'dependencies' => [
'newspackNewsletters' => is_plugin_active( 'newspack-newsletters/newspack-newsletters.php' ),
],
'postType' => Emails::POST_TYPE,
'initial' => \Newspack\Wizards\Newspack\Emails_Section::api_get_email_settings(),
];

wp_enqueue_script( 'newspack-wizards' );

wp_localize_script(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,33 @@
/**
* Emails Section Class.
*
* Surfaces the unified emails management UI in the Newspack > Settings >
* Emails wizard tab. Backed by the unified `newspack_email_configs`
* schema — no parallel registry.
* Surfaces the unified emails management UI as a tab inside Audience >
* Configuration. Backed by the unified `newspack_email_configs` schema —
* no parallel registry. REST routes stay pinned to the `newspack-settings`
* REST_BASE for API stability across the UI move.
*/
class Emails_Section extends Wizard_Section {
/**
* Containing wizard slug.
*
* Not initialized at the class level — `Wizard_Section::__construct`
* unconditionally assigns `$this->wizard_slug = $args['wizard_slug'] ?? ''`,
* which would clobber any default declared here. The registration site
* in `Wizards::init_wizards()` (currently the Audience wizard, slug
* `newspack-audience`) is the single source of truth. Standalone
* instantiation with no args leaves this empty — by design, since
* REST routes use the pinned REST_BASE and don't depend on this value.
*
* @var string
*/
protected $wizard_slug = 'newspack-settings';
protected $wizard_slug;

/**
* REST base path for Emails endpoints.
*
* Hardcoded to 'newspack-settings' for API stability. When NPPD-1538
* later moves the Emails screen from Newspack > Settings to Audience >
* Configuration, this REST path MUST stay at 'newspack-settings' —
* Hardcoded to 'newspack-settings' for API stability. The Emails UI
* moved from Newspack > Settings to Audience > Configuration in
* NPPD-1538, but this REST path stays at 'newspack-settings' —
* external callers and the frontend depend on it. Do NOT change.
*/
const REST_BASE = 'wizard/newspack-settings/emails';
Expand Down Expand Up @@ -81,9 +90,15 @@ private static function is_woocommerce_active(): bool {
* Register the endpoints needed for the wizard screens.
*/
public function register_rest_routes() {
// All routes use self::REST_BASE (pinned to 'wizard/newspack-settings/emails')
// rather than interpolating $this->wizard_slug. This decouples the REST
// surface from the wizard's mount point: when NPPD-1538 moves the Emails
// UI from Newspack > Settings to Audience > Configuration, $wizard_slug
// flips but the REST paths stay put — the frontend's hardcoded URLs and
// any external callers keep working.
register_rest_route(
NEWSPACK_API_NAMESPACE,
'wizard/' . $this->wizard_slug . '/emails',
self::REST_BASE,
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ __CLASS__, 'api_get_email_settings' ],
Expand Down Expand Up @@ -174,7 +189,7 @@ public function register_rest_routes() {
if ( self::is_woocommerce_active() ) {
register_rest_route(
NEWSPACK_API_NAMESPACE,
'wizard/' . $this->wizard_slug . '/emails/(?P<id>[A-Za-z0-9_]+)/toggle',
self::REST_BASE . '/(?P<id>[A-Za-z0-9_]+)/toggle',
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ __CLASS__, 'api_toggle_wc_email' ],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

namespace Newspack\Wizards\Newspack;

use Newspack\Emails;
use Newspack\OAuth;
use Newspack\Wizard;
use Newspack\Everlit_Configuration_Manager;
Expand Down Expand Up @@ -36,6 +35,86 @@ class Newspack_Settings extends Wizard {
*/
protected $capability = 'manage_options';

/**
* Constructor — extends Wizard's setup with an admin_init hook for
* the legacy-Emails-URL redirect (NPPD-1538). The Emails screen
* moved out of Settings into Audience > Configuration; this hook
* forwards explicit `?emails=1` redirect hints to the new home.
*
* @param array $args Wizard arguments — forwarded to the parent.
*/
public function __construct( $args = [] ) {
parent::__construct( $args );
add_action( 'admin_init', [ __CLASS__, 'maybe_redirect_legacy_emails_url' ] );
}

/**
* Redirect legacy `?page=newspack-settings&emails=1` URLs to the new
* Audience > Configuration > Emails home (NPPD-1538).
*
* The `?emails=1` query string is the explicit opt-in marker: it
* tells the server "this request was heading for Emails, not for
* Settings root." Bare `?page=newspack-settings` (no marker) is
* left alone — the Settings page still exists and hosts other
* sections. The hash-only case (`?page=newspack-settings#/emails`)
* is handled client-side in `sections.tsx` because the server
* never sees the URL fragment.
*
* Note: no caller inside this codebase currently generates
* `?emails=1` URLs. The handler is here as a forward-compatible
* deep-link entry point for external integrations (admin emails,
* help-center docs, third-party plugin links) that want to point
* at the new home via query string without depending on JS
* execution. The JS-side redirect in `sections.tsx` covers the
* common case (hash-based bookmarks); this is the complementary
* server path.
*
* Uses `wp_safe_redirect()` (default 302) to match the pattern
* in `Newspack::admin_redirects()`. 302 over 301 so browsers
* don't cache the redirect — if a future feature re-introduces
* a Settings > Emails page, cached 301s would block it.
*/
public static function maybe_redirect_legacy_emails_url() {
// Fastest checks first — this fires on every admin_init across all
// of wp-admin, so cheap $_GET reads short-circuit before the
// capability + AJAX + network-admin guards. Custom-role plugins
// can make current_user_can() non-trivial (user_has_cap filter
// chains, per-request role mapping) and there's no reason to pay
// that on every Dashboard/Plugins/Posts pageload.
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only check on admin URL params, no state change.
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
$emails = isset( $_GET['emails'] ) ? sanitize_text_field( wp_unslash( $_GET['emails'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( 'newspack-settings' !== $page || '1' !== $emails ) {
return;
}
if ( wp_doing_ajax() || is_network_admin() ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// Preserve any other query args on the incoming request
// (e.g. utm_source, highlight=...) so external deep-link
// generators can carry context across the redirect. The
// `page` and `emails` keys are dropped — page is replaced
// with the new home, `emails` was just the hint marker.
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only superglobal access, no state change.
$extra_args = $_GET;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
unset( $extra_args['page'], $extra_args['emails'] );
// `map_deep` (not `array_map`) so array-valued query params
// (e.g. `?foo[]=a&foo[]=b`) are sanitized recursively instead of
// being passed to `sanitize_text_field` as an array — which warns
// and drops the param, losing deep-link context.
$target = add_query_arg(
array_merge( [ 'page' => 'newspack-audience' ], map_deep( wp_unslash( $extra_args ), 'sanitize_text_field' ) ),
admin_url( 'admin.php' )
) . '#/emails';
wp_safe_redirect( $target );
exit;
}

/**
* Get Settings local data
*
Expand Down Expand Up @@ -77,25 +156,6 @@ public function get_local_data() {
'customEvents' => $this->sections['custom-events']->get_data(),
],
],
'emails' => [
'label' => __( 'Emails', 'newspack-plugin' ),
'sections' => [
'emails' => [
'dependencies' => [
'newspackNewsletters' => is_plugin_active( 'newspack-newsletters/newspack-newsletters.php' ),
],
'postType' => Emails::POST_TYPE,
// Intentionally NOT SSR-seeding the email list here.
// `api_get_email_settings()` → `Emails::get_emails()`
// lazily creates the Newspack email posts (via
// `wp_insert_post`) on first read. Seeding it on every
// Settings page load would create those posts even for
// publishers who never open the Emails tab. The Emails
// view fetches the list on mount instead, so creation is
// deferred until the tab is actually opened.
],
],
],
'social' => [
'label' => __( 'Social', 'newspack-plugin' ),
'nextdoor' => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@
}

// DataViews search/filter/icon alignment.
// The Settings emails section is registered with `fullWidth: true` in
// sections.tsx, which adds `.newspack-wizard__content--full-width` to the
// parent and disables the with-wizard-screen negative-margin breakout.
// The Audience Emails route in src/wizards/audience/views/setup/index.js
// passes `className="newspack-wizard__content--full-width"` to its
// withWizardScreen-wrapped tab, which adds that class to the parent and
// disables the with-wizard-screen negative-margin breakout that would
// otherwise pull DataViews past the page edges.
.newspack-emails-tab {
.dataviews__view-actions {
display: flex;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,17 +220,12 @@ describe( 'Emails', () => {
mockCapturedView = null;
mockCapturedOnChangeView = null;
mockCapturedData = [];
window.newspackSettings = {
window.newspackAudience = {
emails: {
sections: {
emails: {
dependencies: {
newspackNewsletters: true,
},
postType: 'newspack_rr_email',
isEmailEnhancementsActive: false,
},
dependencies: {
newspackNewsletters: true,
},
postType: 'newspack_rr_email',
},
};
mockWizardApiFetch.mockImplementation( ( opts, callbacks ) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,21 @@ const CHIPS: { value: ChipValue; label: string }[] = [
];

const Emails = () => {
const emailSections = window.newspackSettings.emails.sections;
const [ pluginsReady, setPluginsReady ] = useState( Boolean( emailSections.emails.dependencies.newspackNewsletters ) );
// Defensive: fall back to an empty shape if the SSR-bootstrap payload
// is missing (e.g., a plugin filter strips the localized object, the
// component is mounted from a non-Audience surface, or a dev-time HMR
// reseed clears window state). Without this, accessing
// `.dependencies.newspackNewsletters` on undefined would throw
// TypeError at mount and crash the entire route.
const emailSettings = window.newspackAudience?.emails ?? { dependencies: {}, postType: '', initial: undefined };
const [ pluginsReady, setPluginsReady ] = useState( Boolean( emailSettings.dependencies?.newspackNewsletters ) );

// Seed from the SSR bootstrap (class-newspack-settings.php passes the same
// Seed from the SSR bootstrap (class-audience-wizard.php passes the same
// shape as api_get_email_settings()) so DataViews renders on first paint
// instead of waiting for the mount-time XHR.
const initial = emailSections.emails.initial;
const initial = emailSettings.initial;
const [ data, setData ] = useState< EmailItem[] >( ( initial?.newspack_emails as EmailItem[] | undefined ) ?? [] );
const postType = initial?.post_type ?? emailSections.emails.postType;
const postType = initial?.post_type ?? emailSettings.postType;
const [ view, setView ] = useState< View >( DEFAULT_VIEW );
const [ activeChip, setActiveChip ] = useState< ChipValue >( 'reader-revenue' );
const [ showSettingsModal, setShowSettingsModal ] = useState( false );
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Audience > Configuration > Emails
*/

/**
* Internal dependencies.
*/
import { withWizardScreen } from '../../../../../../packages/components/src';
import WizardsTab from '../../../../wizards-tab';
import { default as EmailsSection } from './emails';
import WizardSection from '../../../../wizards-section';

function Emails() {
return (
<WizardsTab className="newspack-emails-tab">
<WizardSection>
<EmailsSection />
</WizardSection>
</WizardsTab>
);
}

// withWizardScreen applied at the export to match the sibling tabs
// (Setup, ContentGating, Payment, Campaign, Complete) — each applies
// the HOC at its own module's default export so the parent setup view
// just routes to the component, not to an inline wrapper.
export default withWizardScreen( Emails );
Original file line number Diff line number Diff line change
Expand Up @@ -252,15 +252,11 @@ describe( 'SettingsModal', () => {
// Window globals emails.tsx reads at mount. Empty newspack_emails
// is still truthy, so the grid's mount-time fetch is skipped —
// only the modal will fire a fetch when opened.
window.newspackSettings = {
window.newspackAudience = {
emails: {
sections: {
emails: {
dependencies: { newspackNewsletters: true },
initial: { newspack_emails: [], post_type: 'newspack_em' },
postType: 'newspack_em',
},
},
dependencies: { newspackNewsletters: true },
initial: { newspack_emails: [], post_type: 'newspack_em' },
postType: 'newspack_em',
},
};
} );
Expand Down
Loading
Loading