Custom input components
Example: How to build a Slider component using the Sanity parts system.
Extending Sanity Studio with your own UI components is surprisingly easy. Any React component can be used as an input, as long as it follows a few core conventions:
Given the props
value
- the current value orundefined
if no value is presentonChange
- a function to call when the value should be updated
It must:
- Present an interface that allows editing
props.value
, e.g. through an<input ...>
element - Call
props.onChange
with a patch describing the operation that should be applied on the value - Implement a
.focus()
method, that sets focus on the underlying dom node that represents the input.
It's worth noting that all input widgets are controlled components. This means that calling props.onChange
with a patch describing the mutation is the only way to update and receive an updated props.value
. In addition, all input components must be able to handle undefined
as its props.value
Lets say we'd like to use a custom slider component for editing schema types of number
that has a range
option, e.g.:
{
name: 'rating',
title: 'Rating',
type: 'number',
options: {
range: {min: 0, max: 10, step: 0.2}
}
}
Lets create a simple Slider component:
import React from 'react'
import PropTypes from 'prop-types'
import PatchEvent, {set, unset} from 'part:@sanity/form-builder/patch-event'
const createPatchFrom = value => PatchEvent.from(value === '' ? unset() : set(Number(value)))
export default class Slider extends React.Component {
static propTypes = {
type: PropTypes.shape({
title: PropTypes.string,
options: PropTypes.shape({
min: PropTypes.number.isRequired,
max: PropTypes.number.isRequired,
step: PropTypes.number
}).isRequired
}).isRequired,
value: PropTypes.number,
onChange: PropTypes.func.isRequired
};
// this is called by the form builder whenever this input should receive focus
focus() {
this._inputElement.focus()
}
render() {
const {type, value, onChange} = this.props
const {min, max, step} = type.options.range
return (
<div>
<h2>{type.title}</h2>
<input
type="range"
min={min}
max={max}
step={step}
value={value === undefined ? '' : value}
onChange={event => onChange(createPatchFrom(event.target.value))}
ref={element => this._inputElement = element}
/>
</div>
)
}
}
This slider provides an input with value of props.value
and updates it by calling props.onChange
whenever the user changes the value.
Note: All form builder input components should be able to receive undefined
as props.value
. This indicates "no value". However, <input>
components in React will interpret a value
of undefined
as "this is not a controlled component", and warn if the same input is later passed a non-undefined value. As a consequence, it is usually a good idea to pass something other than undefined to an underlying <input>
-component. If you are unsure what default value to use for signalling "empty value", it is usually safe to use an empty string or null
.
Also note: You'll find more information below, regarding the patch format used internally by the form-builder.
Now, this widget is functionally complete, but it won't appear in forms yet. To achieve that, we'll need the last missing piece:
There are two ways to associate a schema type with a custom input widget:
- Set the one-off
inputComponent
property on the type/field - Implement a custom input resolver to control the input widget for a set of types that matches certain criteria.
The inputComponent
property can be set on all types and fields and will override the default input widget.
import MyCustomStringInput from '../components/MyCustomStringInput'
//...
{
type: 'object',
name: 'typeWithCustomStringInput',
fields: [
{
type: 'string',
name: 'myString',
inputComponent: MyCustomStringInput
}
//...
]
}
If you want more fine grained, programmatic control, you can implement a custom input resolver. To do this, you must provide a function that implements the part:
part:@sanity/form-builder/input-resolver
We start by creating an empty file, lets just call it inputResolver.js
and, for now, just paste in the following snippet:
export default function resolveInput(type) {
//
}
Note: The relative path of this file is not important, we are free to organize the code in our sanity studio as we see fit, but it is usually a good idea to put modules that implement single parts in a folder named ./parts
, and components in ./components
Next up, we register it as implementation of the part "part:@sanity/form-builder/input-resolver"
in our sanity.json
:
{
"implements": "part:@sanity/form-builder/input-resolver",
"path": "./inputResolver.js"
}
import Slider from './Slider'
export default function resolveInput(type) {
if (type.name === 'number' && type.options && type.options.range) {
return Slider
}
}
That's it. You should now see a dull, but workable slider in your forms for number types/fields that have a range
option.
Tip: You can use the resolveInput()
function above to support other custom input widgets too, or have fine grained control over which components that should be used for different types. If it returns nothing/undefined, the default input widget will be used instead.
It's sometimes useful to access the whole document from within your custom input component, not just the value of the current field. You can achieve this by wrapping your component in a HOC, like so:
import {withDocument} from 'part:@sanity/form-builder'
function MyInput(props) {
return (
<div>
Document title: {props.document.title}
{/* ... */}
</div>
)
}
export default withDocument(MyInput)
Currently the following patch types are supported:
{
type: 'set' | 'unset' | 'setIfMissing',
path?: Array<string>,
value?: any
}
A good convention is to create an unset patch if the value should be considered "empty".
path
is optional and only needed if your input deals with complex data structures. For example if you've made a custom geopoint widget and wishes to only update the latitude
property, you can address it in the paths array:
{
type: 'set',
path: ['latitude'],
value: '59.918055'
}
Let's wrap our Slider using a the <FormField />
component from the @sanity/components
plugin:
import React, {PropTypes} from 'react'
import FormField from 'part:@sanity/components/formfields/default'
import PatchEvent, {set, unset} from 'part:@sanity/form-builder/patch-event'
const createPatchFrom = value => PatchEvent.from(value === '' ? unset() : set(Number(value)))
export default class Slider extends React.Component {
static propTypes = {
type: PropTypes.shape({
title: PropTypes.string,
options: PropTypes.shape({
min: PropTypes.number.isRequired,
max: PropTypes.number.isRequired,
step: PropTypes.number
}).isRequired
}).isRequired,
value: PropTypes.number,
onChange: PropTypes.func.isRequired
};
focus() {
this._inputElement.focus()
}
render() {
const {type, value, onChange} = this.props
const {min, max, step} = type.options.range
return (
<FormField label={type.title} description={type.description}>
<input
type="range"
min={min}
max={max}
step={step}
value={value === undefined ? '' : value}
onChange={event => onChange(createPatchFrom(event.target.value))}
ref={element => this._inputElement = element}
/>
</FormField>
)
}
}
This looks better, but we're not quite there yet. We need a unicorn!
Ok, lets add a CSS file: ./slider.css
.slider {
/*
...awesome unicorn styling
see: https://github.com/sanity-io/unicorn-slider/blob/master/src/components/slider.css
*/
}
import React, {PropTypes} from 'react'
import FormField from 'part:@sanity/components/formfields/default'
import styles from './slider.css'
import PatchEvent, {set, unset} from 'part:@sanity/form-builder/patch-event'
const createPatchFrom = value => PatchEvent.from(value === '' ? unset() : set(Number(value)))
export default class Slider extends React.Component {
static propTypes = {
type: PropTypes.shape({
title: PropTypes.string,
options: PropTypes.shape({
min: PropTypes.number.isRequired,
max: PropTypes.number.isRequired,
step: PropTypes.number
}).isRequired
}).isRequired,
value: PropTypes.number,
onChange: PropTypes.func.isRequired
};
focus() {
this._inputElement.focus()
}
render() {
const {type, value, onChange} = this.props
return (
<FormField label={type.title} description={type.description}>
<input
type="range"
className={styles.slider} // needed to use the styling
min={min}
max={max}
step={step}
value={value === undefined ? '' : value}
onChange={event => onChange(createPatch(event.target.value))}
ref={element => this._inputElement = element}
/>
</FormField>
)
}
}
Magic, huh?