Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamically fetch control epic copy #9

Merged
merged 1 commit into from
Jan 22, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"emotion": "^10.0.27",
"emotion-server": "^10.0.27",
"express": "^4.17.1",
"node-fetch": "^2.6.0",
"react": "^16.12.0",
"react-dom": "^16.12.0"
},
Expand All @@ -34,7 +35,9 @@
"@types/aws-serverless-express": "^3.3.2",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.2",
"@types/fetch-mock": "^7.3.2",
"@types/jest": "^24.0.25",
"@types/node-fetch": "^2.5.4",
"@types/react": "^16.9.17",
"@types/react-dom": "^16.9.4",
"@typescript-eslint/eslint-plugin": "^2.16.0",
Expand All @@ -45,6 +48,7 @@
"eslint-config-prettier": "^6.9.0",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-react": "^7.18.0",
"fetch-mock": "^8.3.1",
"inquirer": "^7.0.3",
"jest": "^24.9.0",
"prettier": "^1.19.1",
Expand Down
39 changes: 39 additions & 0 deletions src/api/contributionsApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { fetchDefaultEpicContent } from './contributionsApi';

jest.mock('node-fetch', () => require('fetch-mock').sandbox());
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fetchMock = require('node-fetch');

describe('fetchDefaultEpic', () => {
it('fetches and returns the data in the expected format', async () => {
const url =
'https://interactive.guim.co.uk/docsdata/1fy0JolB1bf1IEFLHGHfUYWx-niad7vR9K954OpTOvjE.json';

const response = {
sheets: {
control: [
{
heading: 'Since you’re here...',
paragraphs: 'First paragraph',
highlightedText: 'Highlighted Text',
},
{
heading: '',
paragraphs: 'Second paragraph',
highlightedText: '',
},
],
},
};

fetchMock.get(url, response);

const epicData = await fetchDefaultEpicContent();

expect(epicData).toEqual({
heading: 'Since you’re here...',
paragraphs: ['First paragraph', 'Second paragraph'],
highlighted: ['Highlighted Text'],
});
});
});
34 changes: 34 additions & 0 deletions src/api/contributionsApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import fetch from 'node-fetch';

const defaultEpicUrl =
'https://interactive.guim.co.uk/docsdata/1fy0JolB1bf1IEFLHGHfUYWx-niad7vR9K954OpTOvjE.json';

export type DefaultEpicContent = {
heading?: string;
paragraphs: string[];
highlighted: string[];
};

export const fetchDefaultEpicContent = async (): Promise<DefaultEpicContent> => {
const response = await fetch(defaultEpicUrl);
if (!response.ok) {
throw new Error(
`Encountered a non-ok response when fetching default epic: ${response.status}`,
);
}
const data = await response.json();
const control = data?.sheets?.control;

return control.reduce(
(
acc: DefaultEpicContent,
item: { heading: string; paragraphs: string; highlightedText: string },
) => {
if (!acc.heading && item.heading) acc.heading = item.heading;
if (item.paragraphs) acc.paragraphs.push(item.paragraphs);
if (item.highlightedText) acc.highlighted.push(item.highlightedText);
return acc;
},
{ paragraphs: [], highlighted: [] },
);
};
117 changes: 70 additions & 47 deletions src/components/DefaultEpic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { palette } from '@guardian/src-foundations';
import { space } from '@guardian/src-foundations';
import { CallToAction } from './CallToAction';

const currencySymbol = '£';

const replacePlaceholders = (content: string): string =>
content.replace(/%%CURRENCY_SYMBOL%%/g, currencySymbol);

// Spacing values below are multiples of 4.
// See https://www.theguardian.design/2a1e5182b/p/449bd5
const wrapperStyles = css`
Expand Down Expand Up @@ -56,51 +61,69 @@ const imageStyles = css`
margin: ${space[1]}px 0;
`;

export const DefaultEpic: React.FC<{}> = ({}) => {
return (
<section className={wrapperStyles}>
<h2 className={headingStyles}>Since you're here...</h2>
<p className={bodyStyles}>
... we have a small favour to ask. More people, like you, are reading and supporting
the Guardian’s independent, investigative journalism than ever before. And unlike
many news organisations, we made the choice to keep our reporting open for all,
regardless of where they live or what they can afford to pay.
</p>
<p className={bodyStyles}>
The Guardian will engage with the most critical issues of our time – from the
escalating climate catastrophe to widespread inequality to the influence of big tech
on our lives. At a time when factual information is a necessity, we believe that
each of us, around the world, deserves access to accurate reporting with integrity
at its heart.
</p>
<p className={bodyStyles}>
Our editorial independence means we set our own agenda and voice our own opinions.
Guardian journalism is free from commercial and political bias and not influenced by
billionaire owners or shareholders. This means we can give a voice to those less
heard, explore where others turn away, and rigorously challenge those in power.
</p>
<p className={bodyStyles}>
We hope you will consider supporting us today. We need your support to keep
delivering quality journalism that’s open and independent. Every reader
contribution, however big or small, is so valuable.{' '}
<strong className={highlightWrapperStyles}>
<span className={highlightStyles}>
Support The Guardian from as little as £1 - and it only takes a minute.
Thank you.
</span>
</strong>
</p>
<div className={buttonWrapperStyles}>
<CallToAction
url="https://support.theguardian.com/uk/contribute"
linkText="Support The Guardian"
/>
<img
src="https://assets.guim.co.uk/images/acquisitions/2db3a266287f452355b68d4240df8087/payment-methods.png"
alt="Accepted payment methods: Visa, Mastercard, American Express and PayPal"
className={imageStyles}
/>
</div>
</section>
);
type Props = {
heading?: string;
paragraphs: string[];
highlighted: string[];
};

type HighlightedProps = {
highlighted: string[];
};

type BodyProps = {
paragraphs: string[];
highlighted: string[];
};

const Highlighted: React.FC<HighlightedProps> = ({ highlighted }: HighlightedProps) => (
<strong className={highlightWrapperStyles}>
{' '}
{highlighted.map((highlightText, idx) => (
<span
key={idx}
className={highlightStyles}
dangerouslySetInnerHTML={{ __html: replacePlaceholders(highlightText) }}
/>
))}
</strong>
);

const EpicBody: React.FC<BodyProps> = ({ highlighted, paragraphs }: BodyProps) => (
<>
{paragraphs.map((paragraph, idx) => (
<p key={idx} className={bodyStyles}>
<span dangerouslySetInnerHTML={{ __html: replacePlaceholders(paragraph) }} />

{highlighted.length > 0 && idx === paragraphs.length - 1 && (
<Highlighted highlighted={highlighted} />
)}
</p>
))}
</>
);

export const DefaultEpic: React.FC<Props> = ({ heading, paragraphs, highlighted }: Props) => (
<section className={wrapperStyles}>
{heading && (
<h2
className={headingStyles}
dangerouslySetInnerHTML={{ __html: replacePlaceholders(heading) }}
/>
)}

<EpicBody paragraphs={paragraphs} highlighted={highlighted} />

<div className={buttonWrapperStyles}>
<CallToAction
url="https://support.theguardian.com/uk/contribute"
linkText="Support The Guardian"
/>
<img
src="https://assets.guim.co.uk/images/acquisitions/2db3a266287f452355b68d4240df8087/payment-methods.png"
alt="Accepted payment methods: Visa, Mastercard, American Express and PayPal"
className={imageStyles}
/>
</div>
</section>
);
59 changes: 37 additions & 22 deletions src/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,48 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { extractCritical } from 'emotion-server';
import { renderHtmlDocument } from './utils/renderHtmlDocument';
import { fetchDefaultEpicContent, DefaultEpicContent } from './api/contributionsApi';
import { DefaultEpic } from './components/DefaultEpic';
import cors from 'cors';

const app = express();
app.use(express.json({ limit: '50mb' }));
const bootApp = (content: DefaultEpicContent): void => {
Copy link
Contributor

Choose a reason for hiding this comment

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

What was the thinking behind not using top-level await in the end:? (Just curious really.) I've seen suggestions that it makes modules a bit harder to reason about/can be surprising, but on the flip-side for the key start file this might not matter and it might help remove the bootApp wrapping and indentation?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good question! It was a combination of things. When I looked it up I was reminded that, as you mentioned here, there is some push-back on whether or not top level await makes sense. I don't think any of the arguments against it affect us right now, but felt like maybe it was setting a precedent and it could be easy to avoid us having the conversation in future.

The other (somewhat major 😸) reason is that it's not supported in TypeScript yet.

Copy link
Contributor

@nicl nicl Jan 21, 2020

Choose a reason for hiding this comment

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

Ha okay, that last argument is pretty slam-dunk :).

It looks like they're adding it in the next version - https://devblogs.microsoft.com/typescript/announcing-typescript-3-8-beta/#top-level-await

const app = express();
app.use(express.json({ limit: '50mb' }));

// Note allows *all* cors. We may want to tighten this later.
app.use(cors());
app.options('*', cors());
// Note allows *all* cors. We may want to tighten this later.
app.use(cors());
app.options('*', cors());

app.get('/', (req, res) => {
const { html, css } = extractCritical(renderToStaticMarkup(<DefaultEpic />));
if (typeof req.query.showPreview !== 'undefined') {
const htmlContent = renderHtmlDocument({ html, css });
res.send(htmlContent);
app.get('/', (req, res) => {
const { html, css } = extractCritical(
renderToStaticMarkup(
<DefaultEpic
heading={content.heading}
paragraphs={content.paragraphs}
highlighted={content.highlighted}
/>,
),
);
if (typeof req.query.showPreview !== 'undefined') {
const htmlContent = renderHtmlDocument({ html, css });
res.send(htmlContent);
} else {
res.send({ html, css });
}
});

// If local then don't wrap in serverless
const PORT = process.env.PORT || 3030;
if (process.env.NODE_ENV === 'development') {
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
} else {
res.send({ html, css });
const server = awsServerlessExpress.createServer(app);
exports.handler = (event: any, context: Context) => {
awsServerlessExpress.proxy(server, event, context);
};
}
});
};

// If local then don't wrap in serverless
const PORT = process.env.PORT || 3030;
if (process.env.NODE_ENV === 'development') {
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
} else {
const server = awsServerlessExpress.createServer(app);
exports.handler = (event: any, context: Context) => {
awsServerlessExpress.proxy(server, event, context);
};
}
fetchDefaultEpicContent()
.then(bootApp)
.catch(error => console.error(`App failed to boot: ${error}`));