Skip to content

Instantly share code, notes, and snippets.

@angeloashmore
Last active May 8, 2020 08:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save angeloashmore/091eb3765073cecfe1837e4f84ffc0a7 to your computer and use it in GitHub Desktop.
Save angeloashmore/091eb3765073cecfe1837e4f84ffc0a7 to your computer and use it in GitHub Desktop.
Improving the developer experience when implementing Prismic Previews with gatsby-source-prismic.

Improving the Prismic Preview developer experience

gatsby-source-prismic v3 introduced a client-side preview system that integrates with Prismic's REST API via prismic-javascript.

The core APIs powering previews is the new usePrismicPreview hook and mergePrismicPreviewData utility function. Under the hood, usePrismicPreview fetches preview data and runs it through the same data transformers used in the source plugin at build-time.

This system provides developers low-level functions to create custom preview systems that work with their existing custom setup. Although powerful, such a system may be more low-level than necessary for most users.

The following RFC presents a higher-level abstraction over usePrismicPreview and mergePrismicPreviewData primarily through the use of higher-order components.

  • Provide a withPreview HOC that orchestrates merging preview data from usePrismicPreview transparently using mergePrismicPreviewData.
  • Provide a withUnpublishedPreview HOC that orchestrates previewing unpublished documents using the 404 page.
  • Provide a withPreviewResolver HOC that orchestrates fetching preview data, saving that data to a global store, and redirecting to an appropriate page.
  • Provide a global store to store and manage preview data with direct access if needed.
  • A clear focus on facilitating data flow and nothing more.

This sets up developers to easily integrate Prismic Previews with gatsby-source-prismic. It also opens the door to external integrations by standardizing the data store.

Table of contents

HOC: withPreview

withPreview simplifies the process of using static page data alongside dynamic preview data by automatically performing the merging.

The withPreview HOC performs the following:

  1. Checks the global preview store for data associated with the page's path.
  2. If preview data is available, use mergePrismicPreviewData to merge the static and preview data objects.

Templates would not need to include usePrismicPreview or mergePrismicPreviewData and instead can be written like standard pages. The data prop received by Gatsby page components will include the merged preview data automatically if needed.

Considerations

  • Should there be a way to opt out of a preview even if preview data is saved? Think: compare published and previewed versions quickly via a toggle.
  • What happens if a site uses multiple Prismic repositories? Should withPreview default to the first repo defined but allow options to pass to usePrismicPreview such as the repository name?

Example usage

Note that the default export is wrapped with the withPreview HOC.

// src/templates/page.js

import * as React from 'react'
import { graphql } from 'gatsby'
import { withPreview } from 'gatsby-source-prismic'

import { Layout } from '../components/Layout'

const PageTemplate = ({ data }) => {
  const page = data.prismicPage

  return (
    <Layout>
      <h1>{page?.data?.title?.text}</h1>
    </Layout>
  )
}

// Note the use of `withPreview` here.
export default withPreview(PageTemplate)

export const query = graphql`
  query PageTemplate($uid: String!) {
    prismicPage(uid: { eq: $uid }) {
      data {
        title {
          text
        }
      }
    }
  }
`

Example implementation

Note that usePreviewStore is a new hook defined in this RFC.

import * as React from 'react'
import { PageProps, Node } from 'gatsby'
import { usePreviewStore, mergePrismicPreviewData } from 'gatsby-source-prismic'

const getDisplayName = (WrappedComponent: React.ComponentType<any>) =>
  WrappedComponent.displayName || WrappedComponent.name || 'Component'

export const withPreview = <TProps extends PageProps>(
  WrappedComponent: React.ComponentType<TProps>,
): React.ComponentType<TProps> => {
  const WithPreview = (props: TProps) => {
    const [state] = usePreviewStore()

    const path = props.location.pathname
    const staticData = props.data
    const previewData = state.pages[path]

    const data = React.useMemo(
      () =>
        state.enabled
          ? mergePrismicPreviewData({
              staticData,
              previewData: previewData as { [key: string]: Node },
            })
          : staticData,
      [state.enabled, staticData, previewData],
    )

    return <WrappedComponent {...props} data={data} />
  }
  WithPreview.displayName = `withPreview(${getDisplayName(WrappedComponent)})`

  return WithPreview
}

HOC: withUnpublishedPreview

withUnpublishedPreview simplifies the process of hooking up preview data to a page that does not yet exist in the Gatsby site. This can be accomplished by hooking into the site's 404 page for a seamless integration.

The withUnpublishedPreview HOC performs the following:

  1. Checks the global preview store for data associated with the page's path.
  2. If preview data is available, find an appropriate component to render (i.e. a Gatsby page template) using a user-provided function or map.
  3. If a component is found, return the component.
  4. If a component is not found, or preview data is not available for the path (i.e. it is a real 404 request), yield to the wrapped component.

Considerations

  • We cannot assume a specific method in which users create pages. A document's type may not be a one-to-one mapping to a template. Think: a document's field determines which template to use and thus requires a function to determine its template.
  • Is recommending the 404 page as a means of showing unpublished previews okay? How much does this rely on the server to correctly point to the 404 page? Any implications in returning a 404 response code?

Example usage

Note that the default export is wrapped with the withUnpublishedPreview HOC along with its options.

Also note that the templates are imported using their default exports as they are wrapped with the withPreview HOC.

// src/pages/404.js

import * as React from 'react'
import { withUnpublishedPreview } from 'gatsby-source-prismic'

import { Layout } from '../components/Layout'

import PageTemplate from '../templates/page'
import BlogPostTemplate from '../templates/blogPost'

const NotFoundPage = () => (
  <Layout>
    <h1>Not found!</h1>
  </Layout>
)

// Note the use of `withUnpublishedPreview` here.
export default withUnpublishedPreview(NotFoundPage, {
  templateMap: {
    page: PageTemplate,
    blogPost: BlogPostTemplate,
  },
})

Example implementation

Note that usePreviewStore is a new hook defined in this RFC.

import * as React from 'react'
import { PageProps, Node } from 'gatsby'
import { usePreviewStore, mergePrismicPreviewData } from 'gatsby-source-prismic'

const getDisplayName = (WrappedComponent: React.ComponentType<any>) =>
  WrappedComponent.displayName || WrappedComponent.name || 'Component'

type WithUnpublishedPreviewArgs = {
  templateMap: Record<string, React.ComponentType<any>>
}

export const withUnpublishedPreview = <TProps extends PageProps>(
  WrappedComponent: React.ComponentType<TProps>,
  options: WithUnpublishedPreviewArgs,
): React.ComponentType<TProps> => {
  const WithUnpublishedPreview = (props: TProps) => {
    const [state] = usePreviewStore()
    const isPreview = state.pages.hasOwnProperty(props.location.pathname)

    if (isPreview) {
      const key = Object.keys(props.data)[0]
      const TemplateComp =
        options.templateMap[
          (props.data as Record<string, { type?: string }>)[key]
            .type as keyof typeof customTypeToTemplate
        ]

      if (TemplateComp) return <TemplateComp {...props} />
    }

    return <WrappedComponent {...props} />
  }
  WithUnpublishedPreview.displayName = `withUnpublishedPreview(${getDisplayName(
    WrappedComponent,
  )})`

  return WithUnpublishedPreview
}

HOC: withPreviewResolver

withPreviewResolver simplifies the process of creating a /preview page that editors land on when clicking the Preview button in Prismic.

The withPreviewResolver HOC performs the following:

  1. Calls usePrismicPreview to detect the Prismic preview.
  2. If the request is a preview, save the data to the global store using usePreviewStore.
  3. Navigate to the previewed document's path.
  4. While this is happening, provide usePrismicPreview's state to the wrapped component as props to show an appropriate UI (e.g. "Loading", "Oops, this isn't a preview").

Considerations

  • This HOC should do the least amount of data management necessary to provide previews. This HOC should not have a UI, for example.

Example use

// src/pages/preview.js

import * as React from 'react'
import { withPreviewResolver } from 'gatsby-source-prismic'

import { linkResolver } from '../linkResolver'

import { Layout } from '../components/Layout'

const PreviewPage = ({ isLoading, isPreview }) => {
  if (isLoading) return <Layout>Loading…</Layout>

  if (isPreview === false) return <Layout>Not a preview</Layout>

  return null
}

// Note the use of `withPreviewResolver` here.
export default withPreviewResolver(PreviewPage, {
  repositoryName: process.env.GATSBY_PRISMIC_REPOSITORY_NAME,
  linkResolver,
})

Example implementation

import * as React from 'react'
import { PageProps, Node } from 'gatsby'
import {
  usePreviewStore,
  mergePrismicPreviewData,
  LinkResolver,
} from 'gatsby-source-prismic'

const getDisplayName = (WrappedComponent: React.ComponentType<any>) =>
  WrappedComponent.displayName || WrappedComponent.name || 'Component'

type WithPreviewResolverArgs = {
  repositoryName: string
  linkResolver?: LinkResolver
}

export const withPreviewResolver = <TProps extends PageProps>(
  WrappedComponent: React.ComponentType<TProps>,
  options: WithPreviewResolverArgs,
): React.ComponentType<TProps> => {
  const WithPreviewResolver = (props: TProps) => {
    const [, dispatch] = usePreviewStore()

    const { isLoading, isPreview, previewData, path } = usePrismicPreview({
      repositoryName: options.repositoryName,
      linkResolver: options.linkResolver,
    })

    React.useEffect(() => {
      if (isPreview && previewData && path) {
        dispatch({
          type: ActionType.AddPage,
          payload: { path, data: previewData },
        })
        navigate(path)
      }
    }, [isPreview, previewData, path, dispatch])

    return (
      <WrappedComponent
        {...props}
        isPreview={isPreview}
        isLoading={isLoading}
      />
    )
  }
  WithPreviewResolver.displayName = `withPreviewResolver(${getDisplayName(
    WrappedComponent,
  )})`

  return WithPreviewResolver
}

Context: usePreviewStore

usePreviewStore is a specialized useReducer made global via React context. It holds all preview data in an object and any related state such as toggling the enabled state.

A dedicated /preview page would use usePreviewStore to save the preview data before redirecting to the document's page. On the document's page, or a site's 404 page if unpublished, usePreviewStore would be used to access the document's previewed data.

Use of usePreviewStore would be abstracted behind withPreview and withUnpublishedPreview, but could still be made available for user-land functionality. Direct access could allow site-specific features like toggling preview data and listing which pages are previewed.

Considerations

  • This requires wrapping the app with a context provider. This could be done automatically with gatsby-browser.js and gatsby-ssr.js.

Example implementation

import * as React from 'react'

export enum ActionType {
  AddPage,
  EnablePreviews,
  DisablePreviews,
}

type Action =
  | {
      type: ActionType.AddPage
      payload: { path: string; data: object }
    }
  | { type: Exclude<ActionType, ActionType.AddPage> }

interface State {
  pages: Record<string, object>
  enabled: boolean
}

const initialState: State = {
  pages: {},
  enabled: false,
}

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case ActionType.AddPage: {
      return {
        ...state,
        pages: {
          ...state.pages,
          [action.payload.path]: action.payload.data,
        },
        enabled: true,
      }
    }

    case ActionType.EnablePreviews: {
      return { ...state, enabled: true }
    }

    case ActionType.DisablePreviews: {
      return { ...state, enabled: false }
    }
  }
}

const PreviewStoreContext = React.createContext([initialState, () => {}] as [
  State,
  React.Dispatch<Action>,
])

export type PreviewStoreProviderProps = {
  children?: React.ReactNode
}

export const PreviewStoreProvider = ({
  children,
}: PreviewStoreProviderProps) => {
  const reducerTuple = React.useReducer(reducer, initialState)

  return (
    <PreviewStoreContext.Provider value={reducerTuple}>
      {children}
    </PreviewStoreContext.Provider>
  )
}

export const usePreviewStore = () => React.useContext(PreviewStoreContext)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment