Lesson
5
Studio customizations
Change the user experience of the Sanity Studio based on roles and deliver a personalized user experience to accelerate editor workflows
Log in to mark your progress for each Lesson and Task
When creating roles, it can be a great next step to change the Sanity Studio and customize the experience of your users based on their role.
Studio customizations might include:
- Showing, hiding or filtering certain content types using the Structure Builder API
- Automatically populating initial values based on a user or role
- Making a field hidden or readonly based on a user or role
- Initializing different plugins / configuration based on a user or role
- Using a role to introduce or adjust a custom component
- Changing the available document actions or ability to create new documents based on a role
- Enabling / disabling workspaces based on a role. There are some caveats to this, covered in the module below.
For each of the above points – with the exception of role-based workspaces – we will introduce customizations based on a reference Github repository
The concept for this lesson is:
Our organization has a number of stores across a number of cities. Each store has offers which are unique to the store. Each of the stores has a manager who should only be able to view, edit and publish offers for their own store. Regional managers can be assigned to multiple stores to manage offers, and administrators can manage all offers across all stores.
Additionally, we publish articles – however, only admins can see all articles. Other users should only be able to work on articles they have created themselves.
In this exercise, we’ll be demonstrating a few example customizations to a Studio – the intention of this is to inspire ideas as to how you might customize your own Sanity Studio(s) to meet your own unique requirements.
To follow along with this scenario you can either take the schema directly from the reference repository or create your own schema.
Create an offer and article schema type
Ensure the offer type has a store, as illustrated below
src/schemaTypes/offer.ts
// define your storestype Store = { id: string name: string}
const stores: Store[] = [ { id: 'store-1', name: 'Store 1', }, { id: 'store-2', name: 'Store 2', },]
// ... rest of offer definitiondefineField({ name: 'store', title: 'Store', type: 'string', options: { list: stores.map((store) => { return { value: store.id, title: store.name, } }), layout: 'radio', },})
Create the below content resources and user roles
- Title: Store 1
- Identifier: store-1
- GROQ filter:
store == "store-1"
- Title: Store 2
- Identifier: store-2
- GROQ filter:
store == "store-2"
- Title: User Articles
- Identifier: user-articles
- GROQ filter:
_type == "article" && (createdBy == identity() || createdBy == $identity)
Why
identity()
and $identity
? This covers all versions of the API and Studio, so will make you a little more bulletproof. $identity
may be removed entirely in a future version of the API.- Title: Store 1 Manager
- Identifier: store-1-manager
- Permissions in all datasets:
- Store 1 - Publish
- Image / file assets - Update and create
- All other resources - No access
- Title: Store 2 Manager
- Identifier: store-2-manager
- Permissions in all datasets:
- Store 2 - Publish
- Image / file assets - Update and create
- All other resources - No access
- Title: Article Editor
- Identifier: article-editor
- Permissions in all datasets:
- User Articles - Publish
- Image / file assets - Update and create
- All other resources - No access
The simplest way to test out the role-based customizations you make is to simply have a number of user accounts to switch between in different browser profiles / incognito windows. This way you can have a admin user and easily make changes to your secondary users' roles to test out the changes to the Studio as you make them in a local development environment. It’s simple and effective.
It's important to bear in mind that if you change your user to remove the administrator role, you might not be able to change it back.
To customize the Studio based on user and role, it’s necessary to know information about the current user. Thankfully, the Studio provides this context in a number of places including – but not limited to – the Structure Builder API, the Tool API, the Document Actions API and hidden / readonly callback functions.
Where this is available, the context will provide the currentUser
object:
CurrentUser type definition
interface CurrentUser { email: string id: string name: string profileImage?: string provider?: string role: string // deprecated, use roles instead roles: Role[]}
// And for reference, the Role:interface Role { name: string title: string description?: string}
Inside React components or custom hooks, you can use the useCurrentUser()
hook to return the same data.
There’s also the userHasRole()
helper function to determine if a particular user has a provided role - this accepts a user object as it’s first argument and a role identifier string as it’s second.
Users can have multiple roles – it’s important to consider this in your customizations and role checks.
Customizing the content types a user sees – and how they see them – can shorten the user journeys in a Studio, greatly improving the overall Studio experience. Let’s expand on some of the principles established in the Structure customization lesson in the Day One with Sanity Studio course.
This initial lesson on the Structure Builder API focused on the StructureBuilder
object which the StructureResolver
returns as it’s first argument. In order to customize this based on users and roles the second argument can be used – the StructureResolverContext
.
This context
object returns a number of useful things in addition to the user – like the getClient
function which can be used to query your dataset(s). The key for customizing based on users is the currentUser
object this context provides. Using this, it's possible to change the Structure for different users and roles.
In the scenario outlined above, one of the steps required is to hide articles from the user if they didn’t create them. If all articles are listed, then users may end up seeing articles they can’t do anything with. This isn’t a great user experience:
Instead, it's better to hide articles the user can’t edit – which declutters the Studio, and displays only the articles the user is able to work with:
To achieve this, we need to do a couple of things. Firstly, the createdBy
field in our article document needs to be populated – this isn’t a system field.
Add a
createdBy
field with an initialValue
to the article schema typesrc/schemaTypes/article.ts
defineField({ name: 'createdBy', title: 'Created By', type: 'string', initialValue: (param, context) => context.currentUser?.id || '', readOnly: (context) => !context.currentUser?.roles.flatMap((r) => r.name).includes('administrator'),})
Note that the field is also made readOnly
for users that aren’t administrators - meaning nobody but admins can change the creator of a document. This could also be hidden.
Another approach for user scoped documents is to create an array of users - perhaps an
allowedUsers
array field. This scales to allow multiple users to access a document.Good to know – If you prefer to choose from a list of users for the
createdBy
field, then the <UserSelectMenu>
component from sanity-plugin-utils
makes for a nice user experience.Gotcha – initial values are only applied at the time of document creation. If you’re adding them retrospectively, you’ll need to patch the user values to pre-existing documents. The Handling schema changes confidently course covers handling data migrations / modifications like this.
Following the addition of this field, we can make use of the StructureResolverContext
to make adjustments in the Structure of our Studio.
Update your Structure to the code below to create your filtered list of articles
src/structure/index.tsx
import {DocumentsIcon} from '@sanity/icons'import type {ConfigContext} from 'sanity'import type { DocumentListBuilder, StructureBuilder, StructureResolver,} from 'sanity/structure'
const API_VERSION = '2023-01-01'
function defineStructure<StructureType>( factory: (S: StructureBuilder, context: ConfigContext) => StructureType,) { return factory}
export const structure: StructureResolver = (S, context) => S.list() .id('root') .title('Content') .items([ S.listItem() .title('Articles') .icon(DocumentsIcon) .schemaType('article') .child(createArticleList(S, context)), // other structure items... ]) const createArticleList = defineStructure<DocumentListBuilder>((S, context) => { const user = context?.currentUser const roles = user?.roles.map((r) => r.name) const isLimited = roles?.includes('article-editor')
let userQuery = `` if (isLimited) { userQuery = `createdBy == $userId` } else { userQuery = `` }
return S.documentTypeList('article') .title(`Articles`) .filter([`_type == "article"`, userQuery].filter(Boolean).join(` && `)) .params({userId: user?.id}) .apiVersion(API_VERSION)})
What's going on here?
Firstly, a defineStructure()
factory function helps Typescript to determine the types we expect for the various customizations being made.
In the Structure, the list item child for articles is passed a createArticleList()
function - this grabs the user from the context and maps their roles into an array of strings. These roles can then be tested to see if the article-editor
role is present.
In the case where this role is present, the article list should be limited. Therefore a filter is applied to the documentTypeList
by joining createdBy == $userId
to our query and passing the user ID as a parameter.
Another requirement is to only show relevant store offers in the Structure. Essentially, the requirement is to show offers only when a user is a store manager, a regional manager or an administrator.
If I am none of these, I don’t want to see anything concerning stores or offers in the Structure in order to remove clutter and improve my experience:
If I am the manager of a single store – “Store 1” – then I’m only interested in offers for my particular store:
However, if I manage multiple stores, I want to be able to access offers for all of these stores:
Note that in the case of managing a single store, the item is at the top-level of the Structure - whereas when I manage multiple, these are nested under an “Offers” list item.
Update your Structure to the code below to add offers to your structure
src/structure/index.tsx
import {DocumentsIcon, HomeIcon, TagIcon} from '@sanity/icons'import type {ConfigContext} from 'sanity'import type { DocumentListBuilder, ListItemBuilder, StructureBuilder, StructureResolver,} from 'sanity/structure'
import {stores} from '../lib/constants'
const API_VERSION = '2023-01-01'
function defineStructure<StructureType>( factory: (S: StructureBuilder, context: ConfigContext) => StructureType,) { return factory}
export const structure: StructureResolver = (S, context) => S.list() .id('root') .title('Content') .items([ S.listItem() .title('Articles') .icon(DocumentsIcon) .schemaType('article') .child(createArticleList(S, context)), ...[createOffers(S, context) as ListItemBuilder].filter(Boolean), ])
const createArticleList = defineStructure<DocumentListBuilder>((S, context) => { const user = context?.currentUser const roles = user?.roles.map((r) => r.name) const isLimited = roles?.includes('article-editor')
let userQuery = `` if (isLimited) { userQuery = `createdBy == $userId` } else { userQuery = `` }
return S.documentTypeList('article') .title(`Articles`) .filter([`_type == "article"`, userQuery].filter(Boolean).join(` && `)) .params({userId: user?.id}) .apiVersion(API_VERSION)})
const createOffers = defineStructure<ListItemBuilder | undefined>((S, context) => { const roles = context?.currentUser?.roles.map((r) => r.name) const storesManaged = roles?.filter((r) => r.endsWith('-manager')).length
if ((storesManaged && storesManaged > 1) || roles?.includes('administrator')) { return S.listItem() .title('Offers') .icon(TagIcon) .child( S.list() .title('Offers') .items(createStoreOffers(S, context) as ListItemBuilder[]), ) } else if (storesManaged && storesManaged == 1) { return createStoreOffers(S, context) as ListItemBuilder }})
const createStoreOffers = defineStructure<ListItemBuilder | ListItemBuilder[]>((S, context) => { const roles = context?.currentUser?.roles.map((r) => r.name)
const userStores = stores .map((store) => { if (roles?.includes(`${store.id}-manager`) || roles?.includes('administrator')) { return S.listItem() .title(`${store.name} Offers`) .icon(HomeIcon) .child( S.documentTypeList('offer') .title(`${store.name} Offers`) .filter(`_type == "offer" && store == $storeId`) .params({storeId: store.id}) .apiVersion(API_VERSION) ) } }) .filter((item) => !!item) || []
return userStores?.length == 1 ? userStores[0] : userStores})
This adds two new functions - createOffers()
and createStoreOffers()
. The createOffers
function essentially checks whether the user has access to multiple stores or a single store. If they manage multiple, then a listItem
is returned to add a top level item. In the case of single store management, then the createStoreOffers()
function is returned.
At the top level, the returned values are spread into an array and a filter applied to remove empty items:
src/structure/index.tsx
...[createOffers(S, context) as ListItemBuilder].filter(Boolean)
This allows us to return undefined
from createOffers
where the user does not have access to a store - as list items can’t accept undefined, this ensures compatibility with the expected types.
The createStoreOffers()
function handles the output of the list item for each store, and includes a child documentTypeList
which includes a filter to filter offers based on store ID.
Here we’ve customized just one section of our Structure, but you could create a completely unique Structures for different user roles in your business. For example, legal teams could just see legal content and merchandising teams could just see product information.
With this type of setup, it’s important to ensure that users can create new documents that they have relevant permissions for. Right now, even though a user profile may have “Store 1 Manager” and “Store 2 Manager” permissions, they won’t be able to create a new document:
The reason for this is that when a new offer document is created, the “store” field will be empty – and these users only have permission to make edits when this field matches their store.
To solve this, initial value templates can be implemented in the Structure. These allow parameters to be passed based on where the user is in the Structure, so users can create offers relevant to the store list they’re viewing.
Update your
sanity.config.ts
to define a new parameterized initial value templatesanity.config.ts
export default defineConfig({ // rest of config schema: { types: schemaTypes, templates: (prev, context) => { const {currentUser} = context
return [ ...prev, { id: 'offer-by-store', title: 'Offer by store', description: 'Offer from a specific store', schemaType: 'offer', parameters: [ {name: 'store', type: 'string'}, {name: 'createdBy', type: 'string'}, ], value: (params: {store: string}) => ({ store: params.store, createdBy: currentUser?.id, }), }, ] }, },})
Note that context can be used here - so contextual values based on the user could be populated - like user ID to a createdBy
field. The key here though is that a store
parameter is defined which can be passed from our Structure.
Update the
createStoreOffers()
function in the Structure with the below code to set an initial value template for the store listsrc/structure/index.tsx
const createStoreOffers = defineStructure<ListItemBuilder | ListItemBuilder[]>((S, context) => { const roles = context?.currentUser?.roles.map((r) => r.name)
const userStores = stores .map((store) => { if (roles?.includes(`${store.id}-manager`) || roles?.includes('administrator')) { return S.listItem() .title(`${store.name} Offers`) .icon(HomeIcon) .child( S.documentTypeList('offer') .title(`${store.name} Offers`) .filter(`_type == "offer" && store == $storeId`) .params({storeId: store.id}) .apiVersion(API_VERSION) .initialValueTemplates([ S.initialValueTemplateItem('offer-by-store', {store: store.id}), ]), ) } }) .filter((item) => !!item) || []
return userStores?.length == 1 ? userStores[0] : userStores})
Now that this is setup, users can create new offers based on the context of the store they’re looking at, and the store
field will be populated automatically:
This concept can be very useful outside of the context of role-based customizations, too!
In addition to adding new documents via the Structure, users might also look to the “Create +” button in the Studio navigation bar.
Similarly to the Structure, the default options here may be disabled due to the permissions and initial values set up, again meaning a user can’t create documents with blank fields.
Update your
sanity.config.ts
with the below code to change the available new document optionssanity.config.ts
export default defineConfig({ // rest of config document: { newDocumentOptions: (prev, {currentUser}) => { let removeTypes = ['media.tag', 'offer']
const storeTemplates = stores.map((store) => { if ( userHasRole(currentUser, `${store.id}-manager`) || userHasRole(currentUser, 'administrator') ) { return { id: `${store.id}-offer`, templateId: 'offer-by-store', title: `${store.name} Offer`, parameters: { store: store.id, }, type: 'template', } } }) as TemplateItem[]
if ( !userHasRole(currentUser, 'administrator') && !userHasRole(currentUser, 'article-editor') ) { removeTypes.push('article') }
return [...prev, ...storeTemplates.filter(Boolean)].filter( (templateItem) => !removeTypes.includes(templateItem.templateId), ) }, },})
This customizes the available options when creating new documents to hide some options based on the user role – for example, removing the article type for users who aren’t admins or article editors and hiding metadata documents created by sanity-plugin-media
.
Additionally, we’re using this menu to add the parameterized initial value templates too. This allows users to create documents for their own stores from the global menu.
There are some occasions that you might want to create custom components that are conditional based on user or role. This might include form components such as custom input components or could include other components like customizing the Studio layout, navbar or tool menu.
Create a
StoreInput
component and add the below code to itsrc/components/StoreInput.tsx
import {Button, Grid, Text} from '@sanity/ui'import {useCallback} from 'react'import {set, type StringInputProps, type TitledListValue, useCurrentUser, userHasRole} from 'sanity'
export default function StoreInput(props: StringInputProps) { const {value, onChange, schemaType} = props
const user = useCurrentUser() const roles = user?.roles.flatMap((r) => r.name)
const handleClick = useCallback( (event: React.MouseEvent<HTMLButtonElement>) => { const nextValue = event.currentTarget.value onChange(set(nextValue)) }, [onChange], )
const stores = (schemaType?.options?.list as Array<TitledListValue<'string'>>)?.filter( (option) => { return roles?.includes(`${option.value}-manager`) || userHasRole(user, 'administrator') }, )
return ( <Grid columns={stores.length} gap={3}> {stores?.map((store) => ( <Button key={store.value} value={store.value} mode={value === store.value ? `default` : `ghost`} tone={value === store.value ? `primary` : `default`} onClick={handleClick} > <Text size={1}>{store.title}</Text> </Button> ))} </Grid> )}
Add the custom input component to the
store
field of your offer documentsrc/schemaTypes/offer.ts
defineField({ name: 'store', title: 'Store', type: 'string', options: { list: stores.map((store) => { return { value: store.id, title: store.name, } }), }, components: { input: StoreInput, },}),
This input component demonstrates a few ideas:
- Replacing the default radio input with buttons.
- When the user is an admin, ensure the default options are rendered.
- When the user is not an admin, adjust the available buttons to only show stores the user has access to, rather than the full list.
The screenshot below illustrates the Studio with no custom input alongside the views of an admin and a user with a limited number of stores. The custom component simplifies the user interface based on the users' role(s).
This is a simple example to illustrate the point – you could implement similar principles to:
- Provide additional instructions alongside a field for users of a certain role.
- Change how a third party API is called in an input component based on user role.
- Amend the UI for content input based on whether a user is a developer or marketer.
Unfortunately, it’s not currently possible to customize plugin initialization based on role, as there is no user context here.
However, it is possible to selective (de)compose elements of a plugin in order to remove elements of a plugin based on user role. For example, the custom tool in sanity-plugin-media
could be removed for some users by adding a custom plugin after the media plugin:
sanity.config.ts
export default defineConfig({ // rest of config plugins: [ // other plugins media(), { name: 'disable-media-tool', tools: (prev, {currentUser}) => userHasRole(currentUser, 'article-editor') ? prev.filter((tool) => tool.name !== 'media') : prev, }, ],})
The Sanity Studio can have multiple workspaces - and each of these can have it’s own configuration. It’s a great idea to enable workspaces per role… for example, if I’m in a certain team, I want to work on certain content, in a particular workspace.
This is possible, but there is a caveat: when the Studio is initialized, the Studio configuration is handled before the user is authenticated - this means we don't know who the user is until after the workspaces are set up.
Because of this, you need to wrap your Studio to embed it in another React application. This allows you to make an API call to the user endpoint prior to initialization and provide different configuration based on the result.
Alternatively, you can amend the Vite configuration to allow for top-level async – but this can impact on some CLI commands such as GraphQL deployments, Typegen and document validation.
If this is something you would like to achieve, please speak to your Solution Architect.
You have 11 uncompleted tasks in this lesson
0 of 11