Customizing Sanity Studio beyond the schema
27 Sep 2021
Table of Contents
Wow, it’s hard to figure out how to customize Sanity Studio’s look and feel, beyond playing with schema
definitions, if you’re not a front-end developer and things don’t really make sense to you to begin with. One of those “understand what to Google in the first place” issues, for sure.
This is a “digital garden” / “notes” post. I’ll keep it updated if I learn more.
…[[[TO DO: add screenshots after code samples]]]…
Default layout
By default, Sanity Studio’s “desktop” comes in 3 “collapsible panes,” from left to right:
- The “List” (of document types you can edit using the Studio).
- The “Document List” (which expands when you click a document type, showing all documents that exist for that type).
- The “Document” “node” (which expands when you click a document; its default “view” is a “form” that lets you edit the document).
You might not know it if you’re not customizing Sanity, but there’s a 4th level inside the 3rd pane:
- 4: The “View” (the lower body of the document pane – views are toggle-able as tabs in the upper body of the document pane – a given “document” pane type might have 3 views – one for editing, one for web preview, one for print preview).
Structure Builder 101
When you’re overriding this with the Structure Builder, you explicitly customize from outside to inside, representing working your way from left to right.
So, you set up a file that ends in .js
and tell the sanity.json
file in your project where you put it:
{
//... rest of the sanity.json file
"parts": [
//... other things like where the "schema.js" lives
{
"name": "part:@sanity/desk-tool/structure",
"path": "./some-folder/my-custom-sb.js"
}
]
}
Do-nothing customization
And then if you wanted to do absolutely nothing at all to it, you’d fill it in with this content that leverages the S.documentTypeListItems()
“convenience method”:
import S from "@sanity/desk-tool/structure-builder";
export default () =>
S.list() // Outermost / farthest left: we want to create a vertical "list" pane.
.title("Content") // Give the pane the title of 'Content' just like it has normally.
.items([ // Define the items that will appear in this far-left pane.
...S.documentTypeListItems(), // Include all document types
]);
Do-nothing variation
Or perhaps this, preparing with hiddenDocTypeNames
for the idea that you might be pulling document types out of Sanity’s “list all items” functionality and handling yourself:
import S from "@sanity/desk-tool/structure-builder";
const hiddenDocTypeNames = [];
const hiddenDocTypes = (listItem) =>
!hiddenDocTypeNames.includes(listItem.getId());
export default () =>
S.list() // Outermost / farthest left: we want to create a vertical "list" pane.
.title("Content") // Give the pane the title of 'Content' just like it has normally.
.items([ // Define the items that will appear in this far-left pane.
...S.documentTypeListItems() // Include all document types
.filter(hiddenDocTypes), // Minus anything we handled by hand
]);
Singleton in the 1st pane
(Issy points out that you can use this to force an “About” page type into a “singleton”), which means it only has a level-1 “List” and a level-3 “Document” editor, without a level-2 “Document List” in between:
import S from "@sanity/desk-tool/structure-builder";
const hiddenDocTypes = (listItem) => !["about"].includes(listItem.getId());
export default () =>
S.list() // Outermost / farthest left: we want to create a vertical "list" pane.
.title("Content") // Give the pane the title of 'Content' just like it has normally.
.items([ // Define the items that will appear in this far-left pane.
S.listItem() // Inside the 'items' array we create our first list item by hand.
.title("About Me") // We give the first list item a title of 'About Me'. This is going to be where we define our singleton!
.child(
// This creates a new child pane when the list item is clicked on. Inside the parentheses we will define what that child pane will contain.
S.editor() // This shows the content editor in the child pane. We specify what this editor displays with the three methods below...
.id("about") // Set this to the name of the singleton.
.schemaType("about") // Here we define which schema this editor will use to generate fields. We want this to use the 'about' schema so it has been filled in accordingly.
.documentId("singleton-about") // This will create a single document with the _id of 'singleton-about' and open it in the editor.
),
...S.documentTypeListItems() // Include all document types
.filter(hiddenDocTypes), // Minus type 'about'
]);
Lessons from Issy
- There’s no reason a List of document types has to be what’s at the far-left. It’s just hard to imagine that not being what you’d want out of Sanity, since that’s the UI everyone thinks of it as, right? But I suppose making an
S.list()
the defaultexport
frommy-custom-sb.js
isn’t strictly mandatory. For example, this 2-linemy-custom-sb.js
gets rid of what I think of as the “1st panel” and moves what I think of as the “2nd panel” (after clicking on “event”) all the way over to the left, making it impossible to get to any other data types:import S from "@sanity/desk-tool/structure-builder"; export default () => S.documentTypeList("event");
- Once you are inside the
.items()
property of anS.list()
, making anS.listItem()
, you define what happens to it by defining its.child()
. That’s how Issy was able to jump straight to “pane 3” – she made its.child()
anS.editor()
.
Merely moving a level-1 list element to the top
As for me, I’m perfectly happy with “pane 1” & “pane 2” the way they are, but I think that if I want to override what goes on at level 3 for, say, the “event” document type, I might need to do what Izzy did with “About” and hand-define it. Only instead of hand-making an S.listItem()
, I could just say I’d like to summon S.documentTypeListItem('event')
.
import S from "@sanity/desk-tool/structure-builder";
const hiddenDocTypes = (listItem) => !["event"].includes(listItem.getId());
export default () =>
S.list() // Outermost / farthest left: we want to create a vertical "list" pane.
.title("Content") // Give the pane the title of 'Content' just like it has normally.
.items([ // Define the items that will appear in this far-left pane.
S.documentTypeListItem("event"), // Hand-include "event"
...S.documentTypeListItems() // Include all document types
.filter(hiddenDocTypes), // Minus type 'event'
]);
Tried it – yup, I can bump an element out of alphabetical order with this trick but otherwise let Sanity render everything.
Working down to level 3 to customize levels 3 and 4
It looks like to set 2 “views” on an “event” document type, but let Sanity take care of making my default data entry form for me, I might do this in my-custom-sb.js
– note the <pre>{JSON.stringify(document.displayed, null, 2)}</pre>
code serving as an S.view.component()
:
import S from "@sanity/desk-tool/structure-builder";
import React from "react";
const hiddenDocTypes = (listItem) => !["event"].includes(listItem.getId());
export default () =>
S.list() // Outermost / farthest left: we want to create a vertical "list" pane.
.title("Content") // Give the pane the title of 'Content' just like it has normally.
.items([ // Define the items that will appear in this far-left pane.
S.documentTypeListItem("event") // Hand-include "event"
.child(
S.documentTypeList("event") // Specify that when you click on "event" from the first pane, you get an event-list second pane
.child((id) =>
S.document() // Specify that when you click on a given "event" from the second pane, you get an "event document" with two sub-view 4th-level tab-panes -- a Sanity-delivered editing form and a simple JSON view.
.schemaType("event")
.documentId(id)
.views([
S.view.form(), // The default form for editing an "event" document
S.view
.component(
(
{ document } // A "View JSON" tab that renders the current selected document's values as JSON
) => (
<pre>{JSON.stringify(document.displayed, null, 2)}</pre>
)
)
.title("View JSON"),
])
)
),
...S.documentTypeListItems() // Include all document types
.filter(hiddenDocTypes), // Minus type 'event'
]);
Yup – it works! “Event” is at the top of the far-left pane, the 2nd pane is as it would be w/o my intervention, and the 3rd pane is mostly as it would be w/o my intervention, except that it now has “Editor” & “View JSON” tabs that toggle me back and forth between a data entry editor and preview of what it looks like as JSON.
If I add an exclamation point to one of the text fields as JSON, even without publishing the document, the exclamation point appears in the JSON view, so the JSON view is getting live draft data. Neat.
Editing just levels 3 and 4 without de-alphabetizing level 1
I can even play with .map()
after S.documentTypeListItems().filter(hiddenDocTypes)
somehow to get “event” back into alphabetical order in the far-left 1st pane while still customizing the 3rd pane of “event” in this way:
import S from "@sanity/desk-tool/structure-builder";
import React from "react";
const hiddenDocTypes = (listItem) => ![].includes(listItem.getId());
const customizeDocumentTypeList = (listItem) => {
const listItemTypeName = listItem.getId();
if (listItemTypeName === "event") {
return S.documentTypeListItem(listItemTypeName).child(
S.documentTypeList("event").child((id) =>
S.document()
.schemaType("event")
.documentId(id)
.views([
S.view.form(), // The default form for editing an "event" document
S.view
.component(
(
{ document } // A "View JSON" tab that renders the current selected document's values as JSON
) => <pre>{JSON.stringify(document.displayed, null, 2)}</pre>
)
.title("View JSON"),
])
)
);
}
return S.documentTypeListItem(listItemTypeName);
};
export default () =>
S.list() // Outermost / farthest left: we want to create a vertical "list" pane.
.title("Content") // Give the pane the title of 'Content' just like it has normally.
.items([
// Define the items that will appear in this pane.
...S.documentTypeListItems() // Include all document types
.filter(hiddenDocTypes)
.map(customizeDocumentTypeList), // Minus type 'event'
]);
The better way
It also looks like perhaps I could have done this in my-custom-sb.js
way more concisely by not bothering with export default
at all, and instead defining an export const getDefaultDocumentNode = ({documentId, schemaType}) => { DEFINE-SOMETHING-HERE }
.
Okay, actually, turns out I need the tiny do-nothing export default () => S.list().title("Content").items([...S.documentTypeListItems()]);
customization I defined up top.
Otherwise, I get this error message:
Error: Structure needs to export a function, an observable, a promise or a stucture builder, got undefined
This works as a way of simply customizing the “3rd pane” of certain document types to add additional 4th-level “views”:
import S from "@sanity/desk-tool/structure-builder";
import React from "react";
export default () =>
S.list() // Outermost / farthest left: we want to create a vertical "list" pane.
.title("Content") // Give the pane the title of 'Content' just like it has normally.
.items([
// Define the items that will appear in this far-left pane.
...S.documentTypeListItems(), // Include all document types
]);
export const getDefaultDocumentNode = ({ documentId, schemaType }) => {
if (schemaType === "event") {
return S.document().views([
S.view.form(), // The default form for editing an "event" document
S.view
.component(
(
{ document } // A "View JSON" tab that renders the current selected document's values as JSON
) => <pre>{JSON.stringify(document.displayed, null, 2)}</pre>
)
.title("View JSON"),
]);
}
};
All right … that’s probably about as far as I want to go with Structure Builder for now.
Customizing a document’s editor form
The holy grail: hacking that S.view.form()
thing.
Of course, with “custom inputs,” I don’t even need to do any of this “structure builder” stuff.
So … huh.
I wonder what the right way to squeeze in a “related list” onto a data entry form is?
…[[[TO DO]]]…