Sanity.io raises $9.3m Series A to redefine content management

Introduction to Custom Diff Components

Customizing how changes between two versions of a data structure are displayed within the Sanity Studio.

With Sanity Studio, you can see, in real time, any changes happening within a given field. You can see these changes down to the smallest detail.

Out of the box, this will render the difference in all basic types - strings, numbers, booleans, arrays and similar. If you have your own custom inputs, the changes will be visible, but will only show the changes between two sets of data.

Often, the difference between two values might not be enough to showcase exactly what happened inside a custom input. A color field changing between two hexadecimal values might not be human readable. A visualization of the two colors would be helpful to humans. A geopoint is an object containing latitude and longitude numbers. Showing the difference between those two numeric numbers won't mean much, but showing two map pins representing those locations can mean a lot.

The Studio API allows you to write your own custom React components that can visualize these changes in ways that make sense to your editors.

Anatomy of a Diff Component

The components responsible for showing the change of a value are called "diff components". These React components receive a structured diff object allowing you to inspect the change values deeply. This object contains data on not only the change itself, but also who made the change and when it was made.

When you compare values over time, different parts of a value may have different authors. For instance, one person may have uploaded an image, while a second person provided the caption, and a third changed a crop. In order to provide this information in a fine-grained manner, the diffs contain annotations.

Annotations can be used to render the name and avatar of the users who did the changes, and also contain the exact timestamp of the change, should you want very fine-grained control.

Actions

There are four basic actions that might happen to a value:

  • action: "changed" - The value changed (from value X to value Y)
  • action: "added" - The value was added (field or array item appeared)
  • action: "removed" - The value was removed (field or array item disappeared)
  • action: "unchanged" - The value was unchanged (item was moved within an array)

These actions can be inspected on the diff object provided to the custom diff component. All of these actions should be accounted for in a diff component.

Adding a custom diff component to a field

To connect a diff component to a schema type, you have two options: Adding a diffComponent property to a schema type or creating a custom "diff resolver."

diffComponent property

The most common way to add a diff component is to provide a diffComponent property on a schema type. This property accepts a component to use.

import {customStringDiff} from '../diffComponents/customStringDiff'

export default {
  name: 'product',
  title: 'Product',
  type: 'document',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string',
      diffComponent: customStringDiff
    },
    // ... Additional fields
  ]
}

In this example, we point to an exported component on a file set up to house the custom diff component.

import React from 'react'
import {DiffFromTo} from '@sanity/field/diff'

function stringDiffComponent({value}) {
  return (<div style={{ borderLeft: "5px solid", padding: "3px", display: "flex"}}>{value}</div>)
}

export function stringDiff({diff, schemaType}) {
  return (
    <DiffFromTo
        diff={diff}
        schemaType={schemaType}
        previewComponent={stringDiffComponent}
        layout="grid"
    />
  )
}

Diff Resolver

The second option is to provide a resolver for the diff. A diff resolver is a function that receives a schema type and an optional diff component and returns either the proper component to use or undefined.

The diff resolver function should be the default export for a file. It then needs to be declared by a part in sanity.json.

{
  // ... Rest of sanity.json
  "parts": [
    {
      "implements": "part:@sanity/base/diff-resolver",
      "path": "path/to/diffResolver.js"
    }
  ]
}

In most cases this approach shouldn't be used, but in certain rare cases it might be required in order to override the defaults. For instance, should you want to provide a diff component for the built-in geopoint type:

import {GeopointFieldDiff} from './GeopointFieldDiff'

export default function geopointDiffResolver({schemaType}) {
  if (schemaType.name === 'geopoint') {
    return GeopointFieldDiff
  }

  return undefined
}

Creating a basic diff component

There are a few helper components to help you build custom diffs. The simplest is the <DiffFromTo /> component. This creates a view that shows an initial state of a field and the new state with an arrow in the middle. You can customize how the those blocks are rendered with a previewComponent.

The following example creates a custom preview for the component to format a country code for a phone number. If a field goes from blank to populated, it will show the addition. If a field goes from populated to empty, it will show a strike-through on the value. If there's a change to the value, it will show a before and after rendered with the preview component.

import React from 'react'
import {DiffFromTo} from '@sanity/field/diff'

export function PhoneNumberFieldDiff({diff, schemaType}) {
  return (
    <DiffFromTo
      diff={diff}
      schemaType={schemaType}
      previewComponent={PhoneNumberPreviewComponent}
    />
  )
}

function PhoneNumberPreviewComponent({value}) {
  const prefix = value.countryCode ? `(${value.countryCode}) ` : ''
  const formatted = `${prefix}${value.number}`
  return <span>{formatted}</span>
}

Creating the states of a diff component

In this example, a different layout is used to render each state of the diff.

Gotcha

It might seem strange at first to provide an unchanged state for our custom diff. This is to provide for states when the value itself hasn't changed, but data around the value have, such as when an item has been moved in an array.

import React from 'react'
import {DiffCard} from '@sanity/field/diff'

export function NumberFieldDiff({diff}) {
  const {fromValue, toValue, action} = diff

  // In certain cases the diff component will be used to render the value, even
  // if there are no changes to the actual _value_. For instance, when an item
  // has been moved within the array, but the actual value did not change.
  if (action === 'unchanged') {
    return <div>{fromValue}</div>
  }

  // If we have both a "from" and "to" value, the value changed
  // "from" and "to" can also be read as "previous" and "next"
  if (typeof fromValue === 'number' && typeof toValue === 'number') {
    return (
      <DiffCard diff={diff}>
        {fromValue}{toValue}
      </DiffCard>
    )
  }

  // If we only have a "previous" value, the value has been unset
  if (typeof fromValue === 'number') {
    return (
      <DiffCard diff={diff}>
        <del>{fromValue}</del>
      </DiffCard>
    )
  }

  // The only remaining option is that the value was added
  return (
    <DiffCard diff={diff}>
      <ins>{toValue}</ins>
    </DiffCard>
  )
}

Helper components

These helpers are available in the @sanity/field package. To use them, make sure to add that package to the dependencies list of your package.json.

<DiffFromTo />

Many diff components will follow a pattern where they have a "preview" component that renders the value, and presents a "from → to" layout, setting the background to the "user color" for the change and adding a tooltip when hovering the diff that shows the author information.

You can use this pattern to graphically showcase the change. This can help the editor to quickly understand at a glance the change that happened.

import React from 'react'
import {DiffFromTo} from '@sanity/field/diff'

export const TelephoneFieldDiff = ({diff, schemaType}) => (
  <DiffFromTo
    diff={diff}
    schemaType={schemaType}
    previewComponent={TelephonePreview}
    layout="inline" // "grid" is also an option
  />
)

function TelephonePreview({value}) {
  const formattedNumber = value.toString().replace(/\d{3}(?=.)/g, '$& ')
  return <>{formattedNumber}</>
}

<ChangeList />

The pattern mentioned in the section on <DiffFromTo /> pairs nicely with the <ChangeList /> helper. Often when displaying a graphical diff using <DiffFromTo />, you may also wish to show the individual fields that were changed to create the overall change. The <ChangeList /> component takes a diff, schemaType, and an array of fields to render individual fields to show.

In this example, we generate a barcode from two fields on an object field type. We use the same visual component used in the custom input, but also use <ChangeList /> to show the individual field changes for barcode and format.

// @ts-check
import React from 'react'
import {DiffFromTo, getDiffAtPath, ChangeList} from '@sanity/field/diff'
import Barcode from 'react-barcode' 

export function barCodeDiff({diff, schemaType}) {
  const barcodeDiff = getDiffAtPath(diff, 'barcode')

  return (<div>
    <DiffFromTo
      diff={diff}
      schemaType={schemaType}
      previewComponent={barCodeDiffPreviewComponent}
      layout="grid"
    />
    <ChangeList
      diff={diff}
      schemaType={schemaType}
      fields={['barcode', 'format']}
    />
  </div>)
}

function barCodeDiffPreviewComponent({value}) {

  return (<div style={{display: "flex", padding: "5px", justifyContent: "center", alignItems: "center"}}>
    <Barcode
      textAlign="center"
      value={value.barcode}
      format={value.format || ''}
      width={1}
    />
    
  </div>)
}

<FromTo />

When creating a completely custom diff component, you can use the <FromTo /> component to specify components to be used for the "From" and "To" states of the component. If you opt for this component, you will need to recreate many of the affordances given, such as tooltips, user background colors, and more.

import React from 'react'
import {FromTo} from '@sanity/field/diff'

export const SomeDiff = () => (
  <FromTo
    from={<div>Old value</div>}
    to={<div>New value</div>}
    layout="inline" // "grid" is also an option
  />
)

<DiffCard />

Renders a container element styled with the appropriate user color, based on the passed diff or annotation.

import React from 'react'
import {DiffCard} from '@sanity/field/diff'

export const SomeDiff = ({diff}) => (
  <DiffCard as="pre" diff={diff}>
    <code>{JSON.stringify(diff.toValue, null, 2)}</code>
  </DiffCard>
)

<DiffTooltip />

Wraps the passed children with a tooltip when hovered, showing information about the actual change - which authors were involved, and when. This component differs slightly in that it can take multiple annotations instead of just a single one, combining the information in a single tooltip. Example:

import {DiffTooltip} from '@sanity/field/diff'
import styles from './MovieReviewDiff.css'

export default function MovieReviewDiff({diff}) {
  const {action, fromValue, toValue} = diff

  if (action === 'unchanged') {
    return <StarMeter value={fromValue} diff={diff} />
  }

  return (
    <>
      {fromValue && <StarMeter value={fromValue} diff={diff} />}
      {fromValue && toValue && '→'}
      {fromValue && <StarMeter value={toValue} diff={diff} />}
    </>
  )
}

function StarMeter({value, diff}) {
  const {numStars, comment} = value
  return (
    <div className={styles.meter}>
      {numStars && (
        <DiffTooltip diff={diff} path="numStars">
          {'⭑'.repeat(numStars)}
        </DiffTooltip>
      )}
      {comment && (
        <DiffTooltip diff={diff} path="comment">
          {comment}
        </DiffTooltip>
      )}
    </div>
  )
}

Helper Hooks

useDiffAnnotationColor(diff, path)

Takes a diff and an optional path as arguments and returns the corresponding user color for it. A user color is an object with the keys background, text and border, each having a hex color as the value.

import {useDiffAnnotationColor} from '@sanity/field/diff'

export function MovieReviewDiff({diff}) {
  const {background, text} = useDiffAnnotationColor(diff, 'numStars')
  return <div style={{background, text}}>Do some fancy logic here</div>
}

useAnnotationColor(annotation)

Like useDiffAnnotationColor, but takes an annotation directly instead of a diff.

import {useAnnotationColor} from '@sanity/field/diff'

export function PhoneNumberDiff({diff}) {
  // Note: `diff.annotation` might not be what you want for many diff types!
  // See "diff annotations" section for more information
  const {background, text} = useAnnotationColor(diff.annotation)
  return <div style={{background, text}}>Do some fancy logic here</div>
}

Helper Functions

getAnnotationAtPath(diff, path)

Takes a diff and a path and tries to resolve an annotation at the given location.

import {getAnnotationAtPath} from '@sanity/field/diff'

export function OpenGraphDiff({diff}) {
  const annotation = getAnnotationAtPath(diff, 'image.secureUrl')
  return <>{/* do something clever here */}<>
}

getDiffAtPath(diff, path)

Takes a diff and a path and tries to resolve a child diff. This is particularly useful for objects and arrays.

import {getDiffAtPath} from '@sanity/field/diff'

export function OpenGraphDiff({diff}) {
  const diff = getDiffAtPath(diff, 'image.secureUrl')
  return <>{/* do something clever here */}<>
}

Shape of the data on a diff object

The diff received in diff components vary depending on the data type represented. All diffs share a common set of properties.

  • action - either added, removed, changed or unchanged.
  • type - the value type, eg string, number etc
  • fromValue and toValue - holding the previous and next value
  • isChanged - a boolean indicating whether or not the actual content changed

Unless the action is unchanged, the diff will also have an annotation property which is often needed to show information about the author of the change, as well as a timestamp and other similar metadata. Make sure you read the section on "diff annotations"!

Strings

String diffs have an additional segments property, which is an array containing parts of the string which have been added, removed or did not change. This is useful when comparing larger chunks of text, as instead of simply saying the paragraph was "replaced", we can say that a single word was changed. It also allows individual segments of the text to be attributed to different authors.

The <DiffString /> component can help render a visualization of these changes - or you can iterate over the segments and render them yourself should you want to.

Arrays

An array rarely has any changes done to "itself", apart from perhaps being set from an undefined state to an empty array. What you are usually interested in is the values it holds, and the locations of those items.

Determining if something was added, removed, or moved within the array is done on a best effort basis, and the algorithm attempts to explain the change with as few "operations" as possible.

Why is it a best guess? From a technical perspective, if an item was at index 0 and now appears at index 1, it has "moved". If that "move" was the result of an item being prepended to the array, the more "natural" way of thinking about it is that it did not move.

Array diffs contain an items property, which itself is an array of changes to the items within the array. Each item has the following properties:

  • hasMoved - a boolean indicating whether or not the array item moved, based on the algorithm's best guess.
  • fromIndex and toIndex - containing the previous and next location of the item within the array. As noted above, fromIndex and toIndex can differ without the hasMoved property being true, because an add/remove operation could have shifted the indexes.
  • diff - represents the actual item diff

Note that the annotation on the array diff is very coarse. When illustrating changes to data inside an Array item, it's best to do that with a diff component for the item with the item's annotations and not for the Array.

Objects

Like arrays, objects rarely have a value of their own, apart from being set from an undefined state to an empty object. To access the individual fields of an object, the fields property is itself an object where the key is the name of the field and the value is the diff for that field. Certain underscore-prefixed fields are ignored when calculating the diff (_id, _type, _createdAt, _updatedAt, _rev).

Diff annotations

Diff annotations hold fine-grained information on the change, and are present on individual "leaves" of the diff structure. It contains an author property (user ID of the person doing the change) and a timestamp for when the change occurred.

At first glance, this may seem like an unnecessary abstraction, but given the granular structure of annotations, we can create a deeper understanding for our editors.

{
  "asset": {
    "_ref": "image-someHash-1024x768-png"
  },
  "crop": {
    "bottom": 100,
    "top": 100,
    "left": 20,
    "right": 20
  },
  "caption": "Kokos is a miniature schnauzer"
}

When comparing two versions of this image data structure, several things can have changed, for instance:

  • The asset reference can have changed
  • The numbers in crop can have appeared
  • The caption may have been edited or created

Using the annotation on the image field itself would give you very coarse information, as if a single author performed the entire set of changes. Instead, we want to look at the annotations on the individual fields, and for the string fields even inspect individual segments of the string.

The getAnnotationAtPath function allows for easy retrieval of these annotations at depth.

Note that in certain cases it might make sense to not be as fine-grained. Theoretically, two users could have edited the crop object above - for instance, one user could have increased the width and a different user only modifying the height, thus touching bottom/top and left/right, respectively. Showing individual authors for each side of a rectangle might be hard to visualize.

Usage with TypeScript

Writing diff components with TypeScript is fully supported. The @sanity/field module not only includes the helper functions, hooks and React components you may want to use, but also a range of type definitions.

When defining a diff component, you can type it as a DiffComponent, and specify which type of diff you expect for the component. For instance, a diff for a geopoint object type might look like this:

import React from 'react'
import {ObjectDiff, DiffComponent} from '@sanity/field/diff'

interface Geopoint {
  lat: number
  lng: number
  alt?: number
}

export const GeopointDiff: DiffComponent<ObjectDiff<Geopoint>> =
  function GeopointDiff(props) {
    const {diff, schemaType} = props
    
    console.log(diff.fromValue) // Geopoint | null | undefined
    console.log(diff.toValue) // Geopoint | null | undefined
    
    return <div>{/* your diff logic here */}</div>
  }

Was this article helpful?