Skip to content

Implement preview mode #70

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

Merged
merged 6 commits into from
May 30, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const i18nConfig = require('./src/i18nConfig');
const supportedLocales = i18nConfig.supportedLocales.map((supportedLocale) => {
return supportedLocale.name;
});
const noRedirectBlacklistedPaths = ['_next']; // Paths that mustn't have rewrite applied to them, to avoid the whole app to behave inconsistently
const noRedirectBlacklistedPaths = ['_next', 'api']; // Paths that mustn't have rewrite applied to them, to avoid the whole app to behave inconsistently
const publicBasePaths = ['robots', 'static', 'favicon.ico']; // All items (folders, files) under /public directory should be added there, to avoid redirection when an asset isn't found
const noRedirectBasePaths = [...supportedLocales, ...publicBasePaths, ...noRedirectBlacklistedPaths]; // Will disable url rewrite for those items (should contain all supported languages and all public base paths)
const withBundleAnalyzer = require('@next/bundle-analyzer')({ // Run with "yarn next:bundle" - See https://www.npmjs.com/package/@next/bundle-analyzer
Expand Down
58 changes: 36 additions & 22 deletions src/components/appBootstrap/MultiversalAppBootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import customerContext from '../../stores/customerContext';
import i18nContext from '../../stores/i18nContext';
import { Theme } from '../../types/data/Theme';
import { MultiversalAppBootstrapProps } from '../../types/nextjs/MultiversalAppBootstrapProps';
import { MultiversalPageProps } from '../../types/pageProps/MultiversalPageProps';
import { SSGPageProps } from '../../types/pageProps/SSGPageProps';
import { SSRPageProps } from '../../types/pageProps/SSRPageProps';
import { initCustomerTheme } from '../../utils/data/theme';
Expand All @@ -20,6 +19,7 @@ import DefaultErrorLayout from '../errors/DefaultErrorLayout';
import BrowserPageBootstrap, { BrowserPageBootstrapProps } from './BrowserPageBootstrap';
import ServerPageBootstrap, { ServerPageBootstrapProps } from './ServerPageBootstrap';
import UniversalGlobalStyles from './UniversalGlobalStyles';
import previewModeContext from '../../stores/previewModeContext';

const fileLabel = 'components/appBootstrap/MultiversalAppBootstrap';
const logger = createLogger({
Expand Down Expand Up @@ -68,7 +68,19 @@ const MultiversalAppBootstrap: React.FunctionComponent<Props> = (props): JSX.Ele
i18nTranslations,
lang,
locale,
}: MultiversalPageProps = pageProps;
}: SSGPageProps | SSRPageProps = pageProps;
let preview,
previewData;

if ('preview' in pageProps) {
// SSG
preview = pageProps.preview;
previewData = pageProps.previewData;
} else {
// SSR
preview = false;
previewData = null;
}

if (!customer || !i18nTranslations || !lang || !locale) {
// Unrecoverable error, we can't even display the layout because we don't have the minimal required information to properly do so
Expand Down Expand Up @@ -143,26 +155,28 @@ const MultiversalAppBootstrap: React.FunctionComponent<Props> = (props): JSX.Ele
}

return (
<i18nContext.Provider value={{ lang, locale }}>
<customerContext.Provider value={customer}>
{/* XXX Global styles that applies to all pages go there */}
<UniversalGlobalStyles theme={theme} />

<ThemeProvider theme={theme}>
{
isBrowser() ? (
<BrowserPageBootstrap
{...browserPageBootstrapProps}
/>
) : (
<ServerPageBootstrap
{...serverPageBootstrapProps}
/>
)
}
</ThemeProvider>
</customerContext.Provider>
</i18nContext.Provider>
<previewModeContext.Provider value={{ preview, previewData }}>
<i18nContext.Provider value={{ lang, locale }}>
<customerContext.Provider value={customer}>
{/* XXX Global styles that applies to all pages go there */}
<UniversalGlobalStyles theme={theme} />

<ThemeProvider theme={theme}>
{
isBrowser() ? (
<BrowserPageBootstrap
{...browserPageBootstrapProps}
/>
) : (
<ServerPageBootstrap
{...serverPageBootstrapProps}
/>
)
}
</ThemeProvider>
</customerContext.Provider>
</i18nContext.Provider>
</previewModeContext.Provider>
);

} else {
Expand Down
10 changes: 9 additions & 1 deletion src/components/pageLayouts/DefaultLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import ErrorPage from '../../pages/_error';
import { SoftPageProps } from '../../types/pageProps/SoftPageProps';
import Sentry from '../../utils/monitoring/sentry';
import DefaultErrorLayout from '../errors/DefaultErrorLayout';
import DefaultPageContainer from './DefaultPageContainer';
import Footer from './Footer';
import Head, { HeadProps } from './Head';
import Nav from './Nav';
import DefaultPageContainer from './DefaultPageContainer';
import PreviewModeBanner from './PreviewModeBanner';

const fileLabel = 'components/pageLayouts/DefaultLayout';
const logger = createLogger({
Expand Down Expand Up @@ -76,6 +77,13 @@ const DefaultLayout: React.FunctionComponent<Props> = (props): JSX.Element => {
style={{ display: 'none' }}
></div>

{
// XXX You may want to enable preview mode during non-production stages only
// process.env.APP_STAGE !== 'production' && (
<PreviewModeBanner />
// )
}

{
!isInIframe && (
<Nav />
Expand Down
128 changes: 128 additions & 0 deletions src/components/pageLayouts/PreviewModeBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/** @jsx jsx */
import { css, jsx } from '@emotion/core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React from 'react';
import Alert from 'reactstrap/lib/Alert';
import usePreviewMode from '../../hooks/usePreviewMode';
import ExternalLink from '../utils/ExternalLink';
import Tooltip from '../utils/Tooltip';

type Props = {}

const stopPreviewMode = async (): Promise<void> => {
window.location.href = `/api/preview?stop=true&redirectTo=${window.location.pathname}`;
};

const startPreviewMode = async (): Promise<void> => {
window.location.href = `/api/preview?redirectTo=${window.location.pathname}`;
};

const ExplanationTooltipOverlay: React.FunctionComponent = (): JSX.Element => {
return (
<span>
When <b>preview mode</b> is enabled, SSG is by-passed and all pages behave as if they were using SSR.<br />
It's a great feature when you want to preview content on staging without having to rebuild your whole application.<br />
<ExternalLink href={'https://nextjs.org/docs/advanced-features/preview-mode'}>Learn more</ExternalLink><br />
<br />
Disabling <b>preview mode</b> will make SSG pages go back to their normal behaviour.<br />
<br />
<i><b>Tip</b>: Make sure to hard refresh the page (<code>cmd+shift+r</code> on MacOs) after enabling it, to refresh the browser cache.</i><br />
<i><b>Tip</b>: We enabled <b>preview mode</b> on the <code>production</code> stage for showcase purpose</i><br />
</span>
);
};

/**
* Displays the banner that warns about whether preview mode is enabled or disabled
*
* Display a link to enable/disable it
*
* @param props
*/
const PreviewModeBanner: React.FunctionComponent<Props> = (props): JSX.Element => {
const { preview } = usePreviewMode();

return (
<Alert
color={'warning'}
css={css`
display: flex;
position: relative;
flex-direction: row;
justify-content: center;
text-align: center;

@media (max-width: 991.98px) {
flex-direction: column;

.right {
display: block;
}
}

@media (min-width: 992px) {
.right {
position: absolute;
right: 20px;
}
}

[class*="fa-"] {
margin-bottom: 1px
}
`}
>
{
preview ? (
<div>
<span>
Preview mode is enabled&nbsp;
<Tooltip
overlay={<ExplanationTooltipOverlay />}
placement={'bottom'}
>
<FontAwesomeIcon icon={['fas', 'question-circle']} size={'xs'} />
</Tooltip>
</span>
<span className={'right'}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
role={'button'}
tabIndex={0}
onClick={stopPreviewMode}
onKeyPress={stopPreviewMode}
>
Leave preview mode
</a>
</span>
</div>
) : (
<div>
<span>
Preview mode is disabled&nbsp;
<Tooltip
overlay={<ExplanationTooltipOverlay />}
placement={'bottom'}
>
<FontAwesomeIcon icon={['fas', 'question-circle']} size={'xs'} />
</Tooltip>
</span>
<span className={'right'}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a
role={'button'}
tabIndex={0}
onClick={startPreviewMode}
onKeyPress={startPreviewMode}
>
Start preview mode
</a>
</span>
</div>
)
}
</Alert>
);
};

export default PreviewModeBanner;
20 changes: 20 additions & 0 deletions src/hooks/usePreviewMode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import previewModeContext, { PreviewModeContext } from '../stores/previewModeContext';

export type PreviewMode = PreviewModeContext

/**
* Hook to access Next.js preview mode data
*
* Uses previewModeContext internally (provides an identical API)
*
* This hook should be used by components in favor of previewModeContext directly,
* because it grants higher flexibility if you ever need to change the implementation (e.g: use something else than React.Context, like Redux/MobX)
*
* @see https://nextjs.org/docs/advanced-features/preview-mode
*/
const usePreviewMode = (): PreviewModeContext => {
return React.useContext(previewModeContext);
};

export default usePreviewMode;
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export const getStaticProps: GetStaticProps<SSGPageProps, StaticParams> = async
const awaitForMs = getRandomInt(1000, 4000);
await waitFor(awaitForMs);

let songId = parseInt(albumId);
let songId = parseInt(albumId, 10);

if (songId > songs.length - 1) { // Handle overflow
songId = 0;
Expand Down
4 changes: 2 additions & 2 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { config, library } from '@fortawesome/fontawesome-svg-core';
import '@fortawesome/fontawesome-svg-core/styles.css';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { faTimesCircle } from '@fortawesome/free-regular-svg-icons';
import { faArrowCircleLeft, faArrowCircleRight, faBook, faBookReader, faCoffee, faHome, faUserCog } from '@fortawesome/free-solid-svg-icons';
import { faArrowCircleLeft, faArrowCircleRight, faBook, faBookReader, faCoffee, faHome, faQuestionCircle, faUserCog } from '@fortawesome/free-solid-svg-icons';
import 'animate.css/animate.min.css'; // Loads animate.css CSS file. See https://github.com/daneden/animate.css
import 'bootstrap/dist/css/bootstrap.min.css'; // Loads bootstrap CSS file. See https://stackoverflow.com/a/50002905/2391795
import 'rc-tooltip/assets/bootstrap.css';
Expand All @@ -22,7 +22,7 @@ import '../utils/monitoring/sentry';
config.autoAddCss = false; // Tell Font Awesome to skip adding the CSS automatically since it's being imported above
library.add(
faGithub,
faArrowCircleLeft, faArrowCircleRight, faBook, faBookReader, faCoffee, faHome, faUserCog,
faArrowCircleLeft, faArrowCircleRight, faBook, faBookReader, faCoffee, faHome, faQuestionCircle, faUserCog,
faTimesCircle,
);

Expand Down
80 changes: 80 additions & 0 deletions src/pages/api/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { NowRequest, NowRequestQuery, NowResponse } from '@now/node';
import { createLogger } from '@unly/utils-simple-logger';
import { PreviewData } from '../../types/nextjs/PreviewData';
import { filterExternalAbsoluteUrl } from '../../utils/js/url';

import Sentry, { configureReq } from '../../utils/monitoring/sentry';

const fileLabel = 'api/preview';
const logger = createLogger({
label: fileLabel,
});

/**
* Those types should be part of @now/node but aren't yet.
*/
type NextPreview = {
clearPreviewData: () => void;
setPreviewData: (previewData: PreviewData) => void;
}

type PreviewModeAPIQuery = {
stop: string;
redirectTo: string;
}

/**
* Preview Mode API
*
* Enables and disables preview mode
*
* @param req
* @param res
*
* @see https://nextjs.org/docs/advanced-features/preview-mode#step-1-create-and-access-a-preview-api-route
* @see https://nextjs.org/docs/advanced-features/preview-mode#clear-the-preview-mode-cookies
*/
export const preview = async (req: NowRequest, res: NowResponse & NextPreview): Promise<void> => {
try {
configureReq(req);

const {
stop = 'false',
redirectTo = '/',
}: PreviewModeAPIQuery = req.query as NowRequestQuery & PreviewModeAPIQuery;
const safeRedirectUrl = filterExternalAbsoluteUrl(redirectTo as string);

// XXX You may want to enable preview mode during non-production stages only
// if (process.env.APP_STAGE !== 'production') {
if (stop === 'true') {
res.clearPreviewData();

logger.info('Preview mode stopped');
} else {
res.setPreviewData({});

logger.info('Preview mode enabled');
}
// } else {
// logger.error('Preview mode is not allowed in production');
// Sentry.captureMessage('Preview mode is not allowed in production', Sentry.Severity.Warning);
// }

res.writeHead(307, { Location: safeRedirectUrl });
res.end();
} catch (e) {
logger.error(e.message);

Sentry.withScope((scope): void => {
scope.setTag('fileLabel', fileLabel);
Sentry.captureException(e);
});

res.json({
error: true,
message: process.env.APP_STAGE === 'production' ? undefined : e.message,
});
}
};

export default preview;
Loading