Happening this week! Hear how Amplitude built a self-serve marketing engine to drive growth 🚀

Parts

Wherein the secrets of defining, implementing and using parts are revealed.

The core mechanism to wrap your head around in order to understand how Sanity can be extended is the Parts System. Sanity is assembled from these "parts", and plug-ins are basically a collection of parts that either adds to, replace or amend the original Sanity parts. Actually, except for the @sanity/core-module, everything you see in the Sanity Studio are plug-ins.

Take a look at the source code for the @sanity/default-layout package which provides the default application layout with the tool-switcher on the left and a heading with a document search and some additional widgets along the top. If you check out its sanity.json-file, you'll see that it provides an implementation of a part called part:@sanity/base/root.

{
  "implements": "part:@sanity/base/root",
  "name": "part:@sanity/default-layout/root",
  "path": "components/DefaultLayoutContainer"
}

This is the part-name for the first component loaded by the Sanity core. If you make a plugin that implements this part, you basically take over the entire content studio experience (that would be a lot of work, but has its applications). You can probably glean from that file that it also defines a tool-switcher part. If you need to, you could provide an alternate tool-switcher by replacing that part using a plug-in.

Revisiting My First Pluginâ„¢, we can take a look at the implementation of the Branding-component of the default layout and see that it has the following import statement:

import BrandLogo from 'part:@sanity/base/brand-logo?'

This is actually the point where our logo-plug-in from the previous chapter appears to the tool-switcher implementation and gets rendered to screen.

So the parts-system is basically a dependency indirection mechanism. It lets your code replace or decorate our code in a reasonably clean and predictable manner. This is why you can still run sanity upgrade in your tricked-out studio and experience that it very often just works!

Admittedly we still have some work to do clearly defining the public interface of Sanity and the list of parts that are stable and thus safe to extend or replace. At this point, we officially support custom logos, fullscreen tools and custom form input widgets. This will all be explained, but first let's delve into the parts system.

Basics

A part starts life as a declaration. In the sanity.json-file of the @sanity/base package, we find the following declaration:

{
"name": "part:@sanity/base/tool",
"description": "Tools available for use within a Sanity configuration" }

This declares the part "part:@sanity/base/tool" in the abstract. It just declares that such a part-name exists without providing an implementation.

Now a part name can only be declared once, but it may be implemented multiple times. This specific part name represents the tool(s) that appear along the left sidebar in the default studio layout. In a default studio, there is only one tool: the Desk Tool. This is the tool that implements the standard content authoring experience. If we take a look at the sanity.json-file of the @sanity/desk-tool we find the following declaration:

  {
"implements": "part:@sanity/base/tool",
"path": "index.js" }

This tells the parts system that this package implements that tool-part, and where to find the implementation.

To consume the part in a different plugin, you simply require or import statements as you normally would in a project utilizing Webpack or Browserify. The only difference is that instead of requiring a file by path, you require a part name:

// Import the FormBuilder component
import FormBuilder from 'part:@sanity/form-builder'

// Add a question-mark to import a part if it is implemented, but not fail
// if it isn't. If it isn't, BrandLogo will be null
import BrandLogo from 'part:@sanity/base/brand-logo?'

// Import every implementation of this part
import tools from 'all:part:@sanity/base/tool'

// Just for fun: List the names of every tool installed in this Sanity studio:
tools.forEach(function(tool) {
  console.log(tool.title)
})

For example:

import React, {PureComponent} from 'react'
import Button from 'part:@sanity/components/buttons/default'

class ClickCounter extends PureComponent {
  constructor() {
    super()
    this.state = {clicks: 0}
    this.handleClick = this.handleClick.bind(this)
  }

  handleClick() {
    this.setState({clicks: clicks++})
  }

  render() {
    return (
      <div>
        Number of clicks: {this.state.clicks}
        <Button onClick={this.handleClick}>Click me!</Button>
      </div>
    )
  }
}

export default ClickCounter

The reason we're using and advocating this part system is to not lock the implementation to a specific plugin. With the above example, the plugin does not need to know which implementation of the button is actually being used, only that the imported button serves the same purpose.

For instance, a user could install the sanity-plugin-beeping-button plugin to have all buttons make a sound when clicked, or install the sanity-plugin-bootstrap to have a bootstrap layout applied to all core components.

For anyone writing custom plugins, the buttons would look and feel right, as long as they used the part name when consuming the button component.

Order of precedence

If multiple plugins implement the same part, the last plugin listed in a projects sanity.json will end up as the authoritative part. This makes it possible to use for instance @sanity/components for most core components and overriding one or more part names with a more specific plugin.

Loading all implementations

In some cases, you want to load all the implementations, not just the last to implement it. This can be done by prefixing the part name with the all: keyword. This pattern is common for things like registering actions in a toolbar, or when creating navigation between different tools.

Example:

import targets from 'all:part:@sanity/base/share'

export default function ShareList(props) {
  return (
    <ul>
      {targets.map(target =>
        <li key={target.name}>
          <a href={target.url(props.targetUrl)}>{target.name}</a>
        </li>
      )}
    </ul>
  )
}

Checking for an implementation

Sometimes you can't know if a part has been implemented in the current Sanity installation. For instance, you could define a part that will be called whenever navigation occurs, but it's not something that is critical to the flow of the application.

In cases like these, you can use the question mark as a postfix:

import onNavigate from 'part:@sanity/base/router/onNavigate?'

if (onNavigate) {
  onNavigate(destinationUrl)
}

Note: You could, in theory, use the all: prefix as mentioned above and check the length property - however, this would cause every implementer to be a part of the generated javascript bundle, even if you are just using a single implementer.

Defining parts

Parts are defined in a plugins sanity.json file, under the parts key.

The two required properties are name and description:

{
  "parts": [
    {
      "name": "part:movies-unlimited/movie-preview",
      "description": "React component that renders a preview of a given movie"
    }
  ]
}

Implementing parts

Parts are implemented in a plugins sanity.json file, under the parts key. The two required properties are implements and path:

{
  "paths": {
    "source": "./src",
    "compiled": "./lib"
  },

  "parts": [
    {
      "implements": "part:movies-unlimited/movie-preview",
      "path": "components/MoviePreview.js",
    },
    {
      "name": "style:movies-unlimited/movie-preview-default",
      "implements": "style:movies-unlimited/movie-preview",
      "path": "./styles/MoviePreview.css"
    }
  ]
}

In the example above there are a few notable things:

  • The plugin defines paths to both a source and a compiled directory. When these are defined, paths defined in part declarations that do not start with a . are relative to these paths based on which context the plugin is running in.
  • The first implementation has a path property that is relative to the paths defined. When the plugin lives inside the plugins folder, the code is automatically run through Babel, which means it'll run ES6 code, and it will look for the file in <plugin-location>/src/components/MoviePreview.js. If you choose to publish a plugin to NPM, however, the code needs to be compiled to browser-compatible ES5 code. When a plugin is installed into node_modules, it will look for the compiled file in <plugin-location>/lib/components/MoviePreview.js. If you do not require any Babel compilation, you can simply skip the declaration of paths.
  • An implementation can also contain a name. This gives the actual implementation a fixed name, that cannot be implemented by other plugins. In the example above, we gave the style part a name. This allows other plugins to compose from the original implementation, extending it as need be.
  • The style part declaration uses a relative path (./styles/MoviePreview.css). The leading dot-notation will make Sanity look for the file relative to sanity.json, instead of using the source and compiled paths defined.

Eslint

Eslint is a pluggable and configurable linter tool for identifying and reporting on patterns in JavaScript.

When using eslint in a Sanity project, it might complain about unresolved import paths when using parts. These errors might look something like:

   1:22  error  Unable to resolve path to module 'part:@sanity/base/router'       import/no-unresolved
   2:24  error  Unable to resolve path to module 'part:@sanity/base/folder-icon'  import/no-unresolved
   3:22  error  Unable to resolve path to module 'part:@sanity/base/file-icon'    import/no-unresolved

This is the eslint rule import/no-unresolved kicking in. You can fix this by adding the following line to your eslint configuration (in this example we use eslintConfig in package.json:

{
  "eslintConfig": {
    "rules": {
      "import/no-unresolved": [2, { "ignore": ["^(all|part):"] }]
    }
  }
}

Was this article helpful?