Skip to content
Merged
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
169 changes: 122 additions & 47 deletions src/apps/frontend/src/preview/HTMLPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,101 +1,176 @@
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<NewsletterEmailFormValues>,
): string => {
const body = document.createElement('body');
body.innerHTML = emailHtml;

const { subjectText, previewText, showPreview = true } = formValues;
body: HTMLElement,
parameters: Partial<NewsletterEmailFormValues>,
) => {
const { subjectText, previewText, showPreview } = parameters;
const subjectTextElement = body.querySelector('h2');
const previewTextElement =
const previewElement =
subjectTextElement?.parentElement?.querySelector<HTMLElement>('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<NewsletterEmailFormValues>();
const [emailHtml, setEmailHtml] = useState<string>();
const [errorMessage, setErrorMessage] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const stringifiedAudience = (formValues.audienceSegments ?? []).join();
const webUrl = requestedUrl ?? article?.webUrl;
const parameters = useWatch<NewsletterEmailFormValues>();
const stringifiedAudience = (parameters.audienceSegments ?? []).join();

const [previewContainerElement, setPreviewContainerElement] =
useState<HTMLElement | null>(null);
const [preview, setPreview] = useState<PreviewData>();
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<PreviewData> => {
if (!webUrl) {
return `<div>No article loaded</div>`;
return {
info: 'No article loaded',
};
}
const audience = stringifiedAudience
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);

if (audience.length === 0) {
return `<div>Choose an audience in order to preview the newsletter email</div>`;
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 (
<HtmlPreview
html={
emailHtml
? modifyContent(emailHtml, formValues)
: `<div>no article html</div> `
}
errorMessage={errorMessage}
isLoading={isLoading}
title={
<figure>
<figcaption css={{ paddingBottom: semanticSpacing.stackSm }}>
<Typography variant="labelFormMd">Newsletter email preview</Typography>
}
widthOptions={[]}
defaultWidth={400}
cssOverrides={css({ width: '440px' })}
/>
</figcaption>

{preview?.error && (
<InlineMessage level="error">{preview.error}</InlineMessage>
)}
{preview?.info && (
<InlineMessage level="information">{preview.info}</InlineMessage>
)}

<div ref={setPreviewContainerElement} css={styles.previewFrame}>
<article></article>
{!preview?.html && (
<div css={styles.placeHolder}>Generated newsletter email preview</div>
)}
{isLoading && (
<div css={styles.spinnerContainer}>
<LoadingSpinner fontSize={100} />
</div>
)}
</div>
</figure>
);
};
122 changes: 122 additions & 0 deletions src/apps/frontend/src/preview/NewsletterEmailPreview.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran storybook locally and I noticed that the story is the same for Empty, channel and delivery timing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point - i've removed the channel and delivery timing stories - the component is now always used with the "newsletter" channel and the only option for deliver timing ("immediate") is now selected by default, so no need for either anymore.

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<NewsletterEmailFormValues>;
functions?: Partial<
Omit<
NotificationFormContextProps,
'channel' | 'notification' | 'updateNotification'
>
>;
};

type Story = StoryObj<StoryArgs>;

const meta: Meta<StoryArgs> = {
title: 'Dispatch/Preview/NewsletterEmailPreview',
component: NewsletterEmailPreview,
args: {
notificationState: defaultAppAlertComposerState,
},
render: function Render({ notificationState, functions, formValues }) {
return useNotificationFormStory(
<NewsletterEmailPreview />,
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,
}),
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,6 @@ export const Empty: Story = {
},
};

export const WithChannel: Story = {};

export const WithDeliveryTiming: Story = {};

export const WithSegments: Story = {
args: {
composerState: populatedNewsletterEmailComposerState,
Expand Down Expand Up @@ -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<HTMLIFrameElement>('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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -58,7 +58,7 @@ export const NewsletterEmailPreviewSection = () => {
Email appearance may vary across different email clients and devices
</Typography>
</AlertBanner>
<HTMLPreview />
<NewsletterEmailPreview />
<TestEmailForm />
</PreviewSection>
);
Expand Down
Loading