feat(emails): inline EmailPreview component#4730
Conversation
2460fae to
10b84df
Compare
thomasguillot
left a comment
There was a problem hiding this comment.
Nice work on the lazy-load + scaling plumbing. Before this lands, please align the rendering contract with our existing NewsletterPreview in newspack-newsletters so the two grids feel like the same surface side-by-side. These are separate plugins — copy the relevant logic into newspack-plugin rather than extracting a shared package. Use newspack-newsletters as a read-only reference for the shape.
Reference (read-only, in newspack-newsletters)
- Component:
src/components/newsletter-preview/index.js— see lines 43–215, particularly theuseInlineStyleshook's iframe-readiness detection (lines 70–193) and theis-ready/fade-in pattern (lines 195–214). - Styles:
src/components/newsletter-preview/style.scss— note the.block-editor-block-preview__container { opacity: 0; transition: opacity 200ms ease-out; }→.is-ready &fade. - Grid wrapper:
src/admin-shell/screens/layouts-list/fields.jsPreviewCard(lines 197–242) — usesaspectRatio: '1',viewportWidth={ 848 }, androotMargin: "200px". - Lazy helper:
src/admin-shell/screens/layouts-list/lazy-preview.js.
Concrete changes to make in this PR
All paths below are in newspack-plugin.
1. Aspect ratio: 1 : 1
File: src/wizards/newspack/views/settings/emails/email-preview.scss
-.newspack-email-preview {
- width: 100%;
- aspect-ratio: 4 / 3;
- overflow: hidden;
- ...
- background: #f6f7f7;
+.newspack-email-preview {
+ width: 100%;
+ aspect-ratio: 1;
+ overflow: hidden;
+ ...
+ background: transparent;Rationale: newsletters layouts grid uses 1 : 1 (<LazyPreview placeholderStyle={{ aspectRatio: '1' }} />). Cards sit side-by-side in the same DataView shell, so matching makes them visually coherent. Transparent background lets the card chrome show through consistently.
2. Source viewport: 848 px wide, intrinsic height
File: src/wizards/newspack/views/settings/emails/email-preview.tsx
Replace the IFRAME_WIDTH = 600 constant and the fixed-size iframe with width 848, height measured from the loaded document. Concretely:
const IFRAME_WIDTH = 848; // matches NewsletterPreview's `viewportWidth={ 848 }`Update the SCSS so __iframe no longer hardcodes height:
&__iframe {
width: 848px;
height: auto; // set dynamically after load
border: 0;
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
pointer-events: none;
}Then measure the natural document height after the iframe loads and set it inline. Add an onLoad handler:
const iframeRef = useRef<HTMLIFrameElement>( null );
const [ iframeHeight, setIframeHeight ] = useState< number | null >( null );
const [ isReady, setIsReady ] = useState( false );
const handleIframeLoad = () => {
const doc = iframeRef.current?.contentDocument;
if ( ! doc ) {
return;
}
// Mirror NewsletterPreview's readiness detection: wait for stylesheets
// and images before measuring + revealing, otherwise the height we read
// is mid-layout and the iframe pops in unstyled.
const awaitLoad = ( el: HTMLLinkElement | HTMLImageElement ) =>
new Promise< void >( resolve => {
el.addEventListener( 'load', () => resolve(), { once: true } );
el.addEventListener( 'error', () => resolve(), { once: true } );
} );
const linkPromises = Array.from(
doc.querySelectorAll< HTMLLinkElement >( 'link[rel="stylesheet"]' )
)
.filter( link => ! link.sheet )
.map( awaitLoad );
const imgPromises = Array.from( doc.querySelectorAll< HTMLImageElement >( 'img' ) )
.filter( img => ! img.complete )
.map( awaitLoad );
// 8s safety so a slow asset never strands the spinner — matches NewsletterPreview line 81.
const safety = setTimeout( () => {
setIframeHeight( doc.body.scrollHeight );
setIsReady( true );
}, 8000 );
Promise.all( [ ...linkPromises, ...imgPromises ] ).then( () => {
clearTimeout( safety );
setIframeHeight( doc.body.scrollHeight );
setIsReady( true );
} );
};And on the <iframe> element:
<iframe
ref={ iframeRef }
className="newspack-email-preview__iframe"
srcDoc={ html }
sandbox=""
tabIndex={ -1 }
title="Email preview"
onLoad={ handleIframeLoad }
style={ {
transform: `scale(${ scale })`,
height: iframeHeight ? `${ iframeHeight }px` : undefined,
} }
/>Scale stays containerWidth / IFRAME_WIDTH — but since IFRAME_WIDTH is now 848, the math changes accordingly.
Also: the PR description says 1200 × 900 but the SCSS as written is 600 × 1800. Please remove that line from the PR description once 848 × auto is the actual value.
3. Ready-state fade-in
Add the is-ready class pattern from NewsletterPreview.
Component:
<div
ref={ containerRef }
className={ `newspack-email-preview${ isReady ? ' is-ready' : '' }` }
>SCSS:
.newspack-email-preview {
...
&__iframe {
...
opacity: 0;
transition: opacity 200ms ease-out;
}
&.is-ready &__iframe {
opacity: 1;
}
}Keep the existing spinner placeholder mounted while ! isReady so the transition is spinner → fade-in iframe (no white flash).
4. Render gating
The current condition is html && ! hasError && ! isLoading && scale > 0. After the changes above, also render the iframe as soon as html arrives (so onLoad can fire and measure) — the iframe itself is hidden by opacity: 0 until isReady flips, and the spinner placeholder stays mounted in the meantime. Concretely:
{ html && ! hasError && scale > 0 && (
<iframe ... />
) }
{ ( isLoading || ( html && ! isReady ) ) && ! hasError && (
<div className="newspack-email-preview__placeholder">
<Spinner />
</div>
) }5. Iframe-state reset on postId change
When postId changes, reset html, isReady, iframeHeight, hasError so the next preview doesn't show stale content while loading. Add to the fetch effect:
useEffect( () => {
if ( ! isVisible ) {
return;
}
setIsLoading( true );
setIsReady( false );
setIframeHeight( null );
setHasError( false );
setHtml( null );
apiFetch...
}, [ isVisible, postId ] );6. Tests
Update src/wizards/newspack/views/settings/emails/email-preview.test.js:
- The "renders iframe on successful fetch" test will need to simulate
onLoad. Aftersrcdocis set, fireiframe.dispatchEvent(new Event('load'))and assert the container gains theis-readyclass. - Stub
iframe.contentDocumentto return an object withquerySelectorAllreturning[]andbody.scrollHeightreturning a number, sohandleIframeLoadresolves immediately. - Add a test for the reset-on-postId-change behaviour.
Out of scope (don't do)
- Don't extract a shared component into a package — these are separate plugins, duplication is fine.
- Don't honour
meta.background_color/ fonts here (system emails don't carry that meta; the newsletters version reads it from layout meta). - Don't reuse
LazyPreviewfrom newsletters — your inlineIntersectionObserveralready matches itsrootMargin: '200px'contract, which is what matters.
Acceptance check
When done, an Email card and a Newsletter Layout card placed side-by-side should:
- Be the same shape (1 : 1).
- Show a comparable amount of vertical content (header + first content block roughly fills the square).
- Fade in from spinner with no FOUC.
- Show transparent background behind the rendered HTML.
There was a problem hiding this comment.
Pull request overview
Adds a live “thumbnail” preview for each transactional email in the Newspack Settings → Emails DataViews UI by introducing a REST endpoint that returns rendered email HTML (with sample token substitution) and a React component that lazy-loads and displays that HTML in a sandboxed iframe.
Changes:
- Backend: Introduces
Email_Previewwith aGET /wizard/newspack-settings/emails/{post_id}/previewREST route to return cached email HTML (or template fallback) with token substitutions. - Frontend: Adds an
EmailPreviewcomponent (IntersectionObserver + ResizeObserver) and wires it into the Emails DataViews “Preview” column. - Tests: Adds PHPUnit coverage for preview HTML/token substitution and Jest coverage for the EmailPreview component behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
includes/wizards/newspack/class-email-preview.php |
Adds REST route + preview HTML rendering/token substitution logic. |
includes/class-newspack.php |
Includes the new class-email-preview.php loader. |
src/wizards/newspack/views/settings/emails/emails.tsx |
Replaces preview placeholder with <EmailPreview /> in the DataViews grid. |
src/wizards/newspack/views/settings/emails/email-preview.tsx |
New React component that lazy-loads and renders sandboxed iframe previews. |
src/wizards/newspack/views/settings/emails/email-preview.scss |
Styling for the preview thumbnail container/iframe/placeholder states. |
src/wizards/newspack/views/settings/emails/emails.scss |
Removes now-obsolete preview placeholder styles. |
tests/unit-tests/email-preview.php |
New PHPUnit tests for backend preview/substitution and REST handler behavior. |
src/wizards/newspack/views/settings/emails/email-preview.test.js |
New Jest tests for lazy-load gating, success/error states, and endpoint path. |
src/wizards/newspack/views/settings/emails/emails.test.js |
Adjusts Emails tests to mock EmailPreview and provide ResizeObserver. |
includes/reader-activation/integrations/README.md |
Adds documentation for the reader activation integrations framework. |
Comments suppressed due to low confidence (3)
includes/wizards/newspack/class-email-preview.php:114
get_sample_substitutions()usesfunction_exists( 'Newspack\Emails::get_reply_to_email' ), butfunction_existsdoes not work for class methods, so this condition will always be false and the preview will ignoreEmails::get_reply_to_email()(and any Reader Activation override). Usemethod_exists( Emails::class, 'get_reply_to_email' )oris_callable( [ Emails::class, 'get_reply_to_email' ] )instead, or just callEmails::get_reply_to_email()directly since this class already depends onNewspack\Emails.
$site_logo_url = wp_get_attachment_url( get_theme_mod( 'custom_logo' ) );
$site_title = get_bloginfo( 'name' );
$site_url = get_bloginfo( 'wpurl' );
$reply_to_email = function_exists( 'Newspack\Emails::get_reply_to_email' ) ? Emails::get_reply_to_email() : get_bloginfo( 'admin_email' );
$site_address = self::get_site_address();
includes/wizards/newspack/class-email-preview.php:127
- The
*CONTACT_EMAIL*substitution builds an<a href="mailto:...">string using$reply_to_emailwithout escaping/sanitizing. Even if the value is expected to be an email, it should be passed throughsanitize_email()and escaped for both thehrefattribute and link text to avoid HTML injection issues and to keep output consistent with WP escaping practices.
'*SITE_ADDRESS*' => $site_address,
'*SITE_CONTACT*' => $site_contact,
'*CONTACT_EMAIL*' => sprintf( '<a href="mailto:%s">%s</a>', $reply_to_email, $reply_to_email ),
src/wizards/newspack/views/settings/emails/email-preview.tsx:71
EmailPreviewassumesResizeObserverexists. In environments withoutResizeObserver, this will throw and prevent the iframe from rendering. Add a feature check with a safe fallback (e.g. compute scale fromnode.getBoundingClientRect().widthonce, or defaultscaleto 1 and skip observing).
// Measure container width and compute iframe scale.
useEffect( () => {
const node = containerRef.current;
if ( ! node ) {
return;
}
const ro = new ResizeObserver( ( [ entry ] ) => {
setScale( entry.contentRect.width / IFRAME_WIDTH );
} );
ro.observe( node );
return () => ro.disconnect();
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…r APIs (NPPD-1525) Address Copilot review feedback on PR #4730: - PHP: guard template include with is_readable() to avoid warnings if the registered template path is missing or unreadable. - JS: add typeof checks for IntersectionObserver and ResizeObserver so the component degrades gracefully in browsers without these APIs (falls back to eager loading and scale=1 respectively). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Addressed in f889cd6. All six items from the review:
Tests updated: |
65d1752 to
ebb58fb
Compare
Backend:
- New Email_Preview class registers GET /wizard/newspack-settings/emails/{id}/preview
- Renders cached EMAIL_HTML_META with sample-value token substitution
- Template fallback for emails with no rendered HTML stored
- Reader/transaction tokens use stable fake values; site/branding tokens use
real publisher values; action URLs use # anchors
Frontend:
- React component with IntersectionObserver lazy-loading via isVisible bridge
- Iframe srcDoc rendering with sandbox="" for security
- Spinner loading state, envelope icon fallback on error
- Component exported; not yet integrated into emails.tsx
Tests:
- 6 PHPUnit tests
- 5 Jest tests
Also fixes a missing tab indentation in includes/class-newspack.php on the
new class-email-preview.php include line.
Replaces the envelope-icon preview placeholder with the live EmailPreview component built in 49b5bae1e. Each card now renders a lazy-loaded iframe thumbnail of the rendered email HTML with sample-value token substitution.
…-1525) CSS container query units (cqw) produce length values, but scale() requires a unitless number. The browser silently discarded the invalid transform, rendering the 600px iframe at full size with only the logo visible through overflow:hidden. Switch to ResizeObserver to measure the container width and set the scale as an inline style. Also removes the dead .newspack-emails__preview-placeholder rule replaced by the EmailPreview component, and restructures the SCSS to use BEM nesting with responsive container sizing (aspect-ratio 4/3). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PHP lint: - class-email-preview.php line 139: auto-fix array double arrow alignment - tests/email-preview.php lines 93/109/123: capitalize docblock summaries Jest: - email-preview.tsx: fix IntersectionObserver TDZ crash. The newspack-scripts Jest mock invokes the observer callback synchronously inside the constructor, before the const observer assignment completes. Moved disconnect logic out of the callback and into the effect cleanup, gated by isVisible dependency. - email-preview.test.js, emails.test.js: add ResizeObserver mock for JSDOM. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Lint: - email-preview.tsx line 47: Prettier wants single arrow param without parens Test: - emails.test.js: mock EmailPreview to render null. The slice 1 test renders the full DataViews grid, which now includes EmailPreview in every card via the wire-in commit 5898082. EmailPreview's fetch effect hangs in the test because the slice 1 test mocks apiFetch only for the settings endpoint, not the preview endpoint. Mocking the component is the right pattern — its own behavior is covered by email-preview.test.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…r APIs (NPPD-1525) Address Copilot review feedback on PR #4730: - PHP: guard template include with is_readable() to avoid warnings if the registered template path is missing or unreadable. - JS: add typeof checks for IntersectionObserver and ResizeObserver so the component degrades gracefully in browsers without these APIs (falls back to eager loading and scale=1 respectively). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…act (NPPD-1525) Address thomasguillot review feedback: 848px source viewport, 1:1 aspect ratio, fade-in via is-ready class with opacity transition, iframe height measured from contentDocument after stylesheets/images load (8s safety timeout), state reset on postId change. Adds tests for is-ready class and reset-on-postId-change behaviour. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…is accessible (NPPD-1525) With sandbox="" the iframe is treated as a unique opaque origin, making contentDocument null. The onLoad handler returned early and never set isReady, leaving the spinner visible indefinitely. allow-same-origin is safe here because allow-scripts is not set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code review — EmailPreview component (slice 2 of unified emails)Reviewed at Bugs1.
|
What this PR does
Adds an inline email preview component (NPPD-1525) that renders a live thumbnail of each email's HTML inside the DataViews grid built in #4727. Replaces the envelope-icon placeholder with real previews — readers see the actual rendered template at a glance.
Base branch is
nppd-945-unified-emails-newspack-slice, nottrunk.The diff in this PR shows only the NPPD-1525 work. The slice 1 changes (
#4727) are excluded from the diff by virtue of the base branch.Code changes
Backend (
includes/wizards/newspack/class-email-preview.php)Email_Previewclass registersGET /wizard/newspack-settings/emails/{post_id}/previewEMAIL_HTML_METApost meta with sample-value token substitution appliedget_option; action URLs use#anchorsFrontend (
src/wizards/newspack/views/settings/emails/)EmailPreviewReact componentisVisiblestate bridge — cards only fetch when scrolled into view, avoiding 24 simultaneous requests on initial rendersrcDocrendering with emptysandbox=""for security (scripts in email HTML cannot execute)transformto match container width while preserving 4:3 aspect ratioemails.tsxto replace the envelope-icon placeholder in the Preview columnLoader fix (
includes/class-newspack.php)class-email-preview.phpinclude lineTests
tests/unit-tests/email-preview.php): substitution map, preview HTML with stored meta, template fallback, nonexistent post, wrong post type returns 404, successful API responsesrc/wizards/newspack/views/settings/emails/email-preview.test.js): loading state, successful iframe render, error fallback, lazy-load gating, correct endpoint pathManual testing
Tested locally on katie.local (Homebrew-served Apache + PHP, symlinked plugin) with the slice 1 grid view. Verified:
All Submissions: