diff --git a/src/apps/frontend/src/preview/HTMLPreview.tsx b/src/apps/frontend/src/preview/HTMLPreview.tsx index abb2e94c..887d7f91 100644 --- a/src/apps/frontend/src/preview/HTMLPreview.tsx +++ b/src/apps/frontend/src/preview/HTMLPreview.tsx @@ -1,54 +1,112 @@ import { css } from '@emotion/react'; -import { HtmlPreview } from '@guardian/stand/HtmlPreviewLoader'; +import { + baseColors, + semanticColors, + semanticSizing, + semanticSpacing, +} from '@guardian/stand'; +import { InlineMessage } from '@guardian/stand/InlineMessage'; import { Typography } from '@guardian/stand/Typography'; import { useCallback, useContext, useEffect, useState } from 'react'; import { useWatch } from 'react-hook-form'; import { NotificationFormContext } from '../compose/NotificationFormContext'; +import { LoadingSpinner } from '../ui/LoadingSpinner'; import type { NewsletterEmailFormValues } from '../utils/notification-forms'; // TO DO - this function will work with the current format of the notification emails -// but we should modidify the template used in email-rendering to include attributes +// but we should modify the template used in email-rendering to include attributes // to more robustly identify the elements to update const modifyContent = ( - emailHtml: string, - formValues: Partial, -): string => { - const body = document.createElement('body'); - body.innerHTML = emailHtml; - - const { subjectText, previewText, showPreview = true } = formValues; + body: HTMLElement, + parameters: Partial, +) => { + const { subjectText, previewText, showPreview } = parameters; const subjectTextElement = body.querySelector('h2'); - const previewTextElement = + const previewElement = subjectTextElement?.parentElement?.querySelector('h2~div'); if (subjectText && subjectTextElement) { subjectTextElement.innerText = subjectText; } - if (previewTextElement) { - previewTextElement.innerText = showPreview ? (previewText ?? '') : ''; + if (previewElement) { + previewElement.innerText = showPreview && previewText ? previewText : ''; } Array.from(body.querySelectorAll('a')).forEach((link) => link.removeAttribute('href'), ); +}; - return body.innerHTML; +type PreviewData = { + html?: string | undefined; + error?: string | undefined; + info?: string | undefined; }; -export const HTMLPreview = () => { +const styles = { + previewFrame: css({ + maxWidth: 440, + borderWidth: semanticSizing.border.default, + borderColor: semanticColors.border.strong, + borderStyle: 'solid', + backgroundColor: baseColors.neutral[900], + position: 'relative', + }), + placeHolder: css({ + minHeight: 300, + backgroundColor: baseColors.neutral[700], + color: baseColors.neutral[0], + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }), + spinnerContainer: css({ + position: 'absolute', + inset: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + backdropFilter: 'blur(2px)', + }), +}; + +export const NewsletterEmailPreview = () => { const { composerState: { article, requestedUrl }, requestEmailHtml, } = useContext(NotificationFormContext); - const formValues = useWatch(); - const [emailHtml, setEmailHtml] = useState(); - const [errorMessage, setErrorMessage] = useState(); - const [isLoading, setIsLoading] = useState(false); - const stringifiedAudience = (formValues.audienceSegments ?? []).join(); const webUrl = requestedUrl ?? article?.webUrl; + const parameters = useWatch(); + const stringifiedAudience = (parameters.audienceSegments ?? []).join(); + + const [previewContainerElement, setPreviewContainerElement] = + useState(null); + const [preview, setPreview] = useState(); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + const articleElement = previewContainerElement?.querySelector('article'); + if (!articleElement) { + return; + } + articleElement.innerHTML = preview?.html ?? ''; + if (preview?.html) { + modifyContent(articleElement, parameters); + } + }, [previewContainerElement, preview?.html, parameters]); + + useEffect(() => { + const articleElement = previewContainerElement?.querySelector('article'); + if (!articleElement) { + return; + } + modifyContent(articleElement, parameters); + }, [parameters, previewContainerElement]); - const fetchHtml = useCallback(async () => { + const getPreviewData = useCallback(async (): Promise => { if (!webUrl) { - return `No article loaded`; + return { + info: 'No article loaded', + }; } const audience = stringifiedAudience .split(',') @@ -56,46 +114,63 @@ export const HTMLPreview = () => { .filter((item) => item.length > 0); if (audience.length === 0) { - return `Choose an audience in order to preview the newsletter email`; + return { + info: 'Choose an audience in order to preview the newsletter email', + }; } const result = await requestEmailHtml({ article: webUrl, audience: audience, }); + if (!result.success) { - throw result.failure; + return { + error: result.failure.message, + }; } - return result.data.html; + return { + html: result.data.html, + }; }, [webUrl, requestEmailHtml, stringifiedAudience]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- ok setIsLoading(true); - setErrorMessage(undefined); - fetchHtml() - .then(setEmailHtml) - .catch((err) => { - console.error(err); - setErrorMessage('failed to load'); - }) - .finally(() => setIsLoading(false)); - }, [fetchHtml]); + setPreview((preview) => ({ + errorMessage: undefined, + info: undefined, + html: preview?.html, + })); + void getPreviewData().then((result) => { + setPreview(result); + setIsLoading(false); + }); + }, [getPreviewData]); return ( - no article html ` - } - errorMessage={errorMessage} - isLoading={isLoading} - title={ + + Newsletter email preview - } - widthOptions={[]} - defaultWidth={400} - cssOverrides={css({ width: '440px' })} - /> + + + {preview?.error && ( + {preview.error} + )} + {preview?.info && ( + {preview.info} + )} + + + + {!preview?.html && ( + Generated newsletter email preview + )} + {isLoading && ( + + + + )} + + ); }; diff --git a/src/apps/frontend/src/preview/NewsletterEmailPreview.stories.tsx b/src/apps/frontend/src/preview/NewsletterEmailPreview.stories.tsx new file mode 100644 index 00000000..2cbabf19 --- /dev/null +++ b/src/apps/frontend/src/preview/NewsletterEmailPreview.stories.tsx @@ -0,0 +1,122 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, waitFor, within } from 'storybook/test'; +import type { NotificationFormContextProps } from '../compose/NotificationFormContext'; +import { fetchFailError } from '../testing/api-fixtures'; +import { articleFixture } from '../testing/capi-fixtures'; +import { mockRequestEmailHtmlWithoutDelay } from '../testing/mock-fetch-email'; +import { useNotificationFormStory } from '../testing/useNotificationFormStory'; +import type { NotificationComposerState } from '../types'; +import { defaultAppAlertComposerState } from '../utils/notification-composer-reducer'; +import type { NewsletterEmailFormValues } from '../utils/notification-forms'; +import { NewsletterEmailPreview } from './HTMLPreview'; + +type StoryArgs = { + notificationState: NotificationComposerState; + formValues?: Partial; + functions?: Partial< + Omit< + NotificationFormContextProps, + 'channel' | 'notification' | 'updateNotification' + > + >; +}; + +type Story = StoryObj; + +const meta: Meta = { + title: 'Dispatch/Preview/NewsletterEmailPreview', + component: NewsletterEmailPreview, + args: { + notificationState: defaultAppAlertComposerState, + }, + render: function Render({ notificationState, functions, formValues }) { + return useNotificationFormStory( + , + notificationState, + functions, + 'newsletter', + formValues, + ); + }, +}; + +export default meta; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('No article loaded')).toBeInTheDocument(); + }, +}; + +export const WithContentNoAudience: Story = { + args: { + notificationState: { + ...defaultAppAlertComposerState, + article: articleFixture, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText( + 'Choose an audience in order to preview the newsletter email', + ), + ).toBeInTheDocument(); + }, +}; + +export const WithContentAndAudience: Story = { + args: { + notificationState: { + ...defaultAppAlertComposerState, + article: articleFixture, + }, + formValues: { + audienceSegments: ['UK'], + }, + functions: { + requestEmailHtml: mockRequestEmailHtmlWithoutDelay, + }, + }, + play: async ({ canvasElement }) => { + await waitFor(() => + expect( + canvasElement.querySelector('article table h2'), + ).toBeInTheDocument(), + ); + }, +}; + +export const Loading: Story = { + args: { + notificationState: { + ...defaultAppAlertComposerState, + article: articleFixture, + }, + formValues: { + audienceSegments: ['UK'], + }, + functions: { + requestEmailHtml: () => new Promise(() => {}), + }, + }, +}; +export const FailedToLoad: Story = { + args: { + notificationState: { + ...defaultAppAlertComposerState, + article: articleFixture, + }, + formValues: { + audienceSegments: ['UK'], + }, + functions: { + requestEmailHtml: () => + Promise.resolve({ + success: false, + failure: fetchFailError, + }), + }, + }, +}; diff --git a/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.stories.tsx b/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.stories.tsx index 1716f480..ffd99222 100644 --- a/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.stories.tsx +++ b/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.stories.tsx @@ -62,10 +62,6 @@ export const Empty: Story = { }, }; -export const WithChannel: Story = {}; - -export const WithDeliveryTiming: Story = {}; - export const WithSegments: Story = { args: { composerState: populatedNewsletterEmailComposerState, @@ -137,11 +133,10 @@ export const PreviewTextToggleUpdatesHtmlAndTestEmail: Story = { requestPreviewTextTestEmail.mockClear(); const canvas = within(canvasElement); const toggle = canvas.getByRole('button', { name: 'Show preview text' }); - const preview = canvas.getByTitle('preview'); + const previewArticleElement = canvasElement.querySelector('figure article'); + const previewBodyText = () => - new DOMParser() - .parseFromString(preview.srcdoc, 'text/html') - .querySelector('h2 ~ div')?.textContent; + previewArticleElement?.querySelector('h2 ~ div')?.textContent; await waitFor(() => expect(previewBodyText()).toBe('Saved preview text')); await userEvent.type( diff --git a/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.tsx b/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.tsx index b786cce4..2d5d1958 100644 --- a/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.tsx +++ b/src/apps/frontend/src/preview/NewsletterEmailPreviewSection.tsx @@ -12,7 +12,7 @@ import { defaultNewsletterEmailFormValues, type NewsletterEmailFormValues, } from '../utils/notification-forms'; -import { HTMLPreview } from './HTMLPreview'; +import { NewsletterEmailPreview } from './HTMLPreview'; import { PreviewSection } from './PreviewSection'; export const NewsletterEmailPreviewSection = () => { @@ -58,7 +58,7 @@ export const NewsletterEmailPreviewSection = () => { Email appearance may vary across different email clients and devices - + ); diff --git a/src/apps/frontend/src/testing/mock-fetch-email.ts b/src/apps/frontend/src/testing/mock-fetch-email.ts index ff10e6f0..7dbab775 100644 --- a/src/apps/frontend/src/testing/mock-fetch-email.ts +++ b/src/apps/frontend/src/testing/mock-fetch-email.ts @@ -59,3 +59,14 @@ export const mockRequestEmailHtml: RequestEmailHtml = (request) => { }, 500); }); }; + +export const mockRequestEmailHtmlWithoutDelay: RequestEmailHtml = (request) => + Promise.resolve({ + success: true, + data: { + html: buildHtml(request.audience), + articleId: + 'technology/2026/jul/28/apple-second-ever-5tn-company-as-investors-flee-ai-stocks', + newsletterId: 'some-newsletter', + }, + });