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.
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.
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.
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>
)
}
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.
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"
}
]
}
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 acompiled
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 theplugins
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 intonode_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 ofpaths
. - 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 tosanity.json
, instead of using thesource
andcompiled
paths defined.
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):"] }]
}
}
}