Skip to content

Commit

Permalink
feat(titles): Update titles inside desk tool (#4887)
Browse files Browse the repository at this point in the history
* feat(titles): Update titles inside desk tool

When the user is moving through documents and panes, update the
title property to be  more specific to the current route

* test(titles): Adds tests to Desktitle component

* feat(titles): Adds controlsDocumentTitle property to Tool

* fix(desk): try simplify title creation, always display the last document pane title where applicable (#4898)

---------

Co-authored-by: Robin Pyon <robinpyon@users.noreply.github.com>
  • Loading branch information
pedrobonamin and robinpyon committed Sep 6, 2023
1 parent b3b365e commit 2ace4f1
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 3 deletions.
5 changes: 5 additions & 0 deletions packages/sanity/src/core/config/types.ts
Expand Up @@ -141,6 +141,11 @@ export interface Tool<Options = any> {
*/
title: string

/**
* Determines whether the tool will control the `document.title`.
*/
controlsDocumentTitle?: boolean

/**
* Gets the state for the given intent.
*
Expand Down
8 changes: 6 additions & 2 deletions packages/sanity/src/core/studio/StudioLayout.tsx
Expand Up @@ -84,15 +84,19 @@ export function StudioLayout() {
const mainTitle = title || startCase(name)

if (activeToolName) {
return `${mainTitle} ${startCase(activeToolName)}`
return `${startCase(activeToolName)} | ${mainTitle}`
}

return mainTitle
}, [activeToolName, name, title])
const toolControlsDocumentTitle = !!activeTool?.controlsDocumentTitle

useEffect(() => {
if (toolControlsDocumentTitle) {
return
}
document.title = documentTitle
}, [documentTitle])
}, [documentTitle, toolControlsDocumentTitle])

const handleSearchFullscreenOpenChange = useCallback((open: boolean) => {
setSearchFullscreenOpen(open)
Expand Down
170 changes: 170 additions & 0 deletions packages/sanity/src/desk/components/deskTool/DeskTitle.test.tsx
@@ -0,0 +1,170 @@
import React from 'react'
import {render} from '@testing-library/react'
import {Panes} from '../../structureResolvers'
import * as USE_DESK_TOOL from '../../useDeskTool'
import {DeskTitle} from './DeskTitle'
import * as SANITY from 'sanity'

jest.mock('sanity')

describe('DeskTitle', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore it's a minimal mock implementation of useDeskTool
jest.spyOn(USE_DESK_TOOL, 'useDeskTool').mockImplementation(() => ({
structureContext: {title: 'My Desk Tool'},
}))
describe('Non document panes', () => {
const mockPanes: Panes['resolvedPanes'] = [
{
id: 'content',
type: 'list',
title: 'Content',
},
{
id: 'author',
type: 'documentList',
title: 'Author',
schemaTypeName: 'author',
options: {
filter: '_type == $type',
},
},
{
id: 'documentEditor',
type: 'document',
title: 'Authors created',
options: {
id: 'fake-document',
type: 'author',
},
},
]
beforeEach(() => {
document.title = 'Sanity Studio'
})
it('renders the correct title when the content pane is open', () => {
render(<DeskTitle resolvedPanes={mockPanes.slice(0, 1)} />)
expect(document.title).toBe('Content | My Desk Tool')
})
it('renders the correct title when an inner pane is open', () => {
render(<DeskTitle resolvedPanes={mockPanes.slice(0, 2)} />)
expect(document.title).toBe('Author | My Desk Tool')
})
it('renders the correct title when the document pane has a title', () => {
render(<DeskTitle resolvedPanes={mockPanes} />)
expect(document.title).toBe('Authors created | My Desk Tool')
})
it('should not update the title if no panes are available', () => {
render(<DeskTitle resolvedPanes={[]} />)
expect(document.title).toBe('Sanity Studio')
})
})
describe('With document panes', () => {
const mockPanes: Panes['resolvedPanes'] = [
{
id: 'content',
type: 'list',
title: 'Content',
},
{
id: 'author',
type: 'documentList',
title: 'Author',
schemaTypeName: 'author',
options: {
filter: '_type == $type',
},
},
{
id: 'documentEditor',
type: 'document',
title: '',
options: {
id: 'fake-document',
type: 'author',
},
},
]

const doc = {
name: 'Foo',
_id: 'drafts.fake-document',
_type: 'author',
_updatedAt: '',
_createdAt: '',
_rev: '',
}
const editState = {
ready: true,
type: 'author',
draft: doc,
published: null,
id: 'fake-document',
transactionSyncLock: {enabled: false},
liveEdit: false,
}
const valuePreview = {
isLoading: false,
value: {
title: doc.name,
},
}
const useSchemaMock = () =>
({
get: () => ({
title: 'Author',
name: 'author',
type: 'document',
}),
}) as unknown as SANITY.Schema

it('should not update the when the document is still loading', () => {
const useEditStateMock = () => ({...editState, ready: false})
const useValuePreviewMock = () => valuePreview
jest.spyOn(SANITY, 'useSchema').mockImplementationOnce(useSchemaMock)
jest.spyOn(SANITY, 'useEditState').mockImplementationOnce(useEditStateMock)
jest.spyOn(SANITY, 'unstable_useValuePreview').mockImplementationOnce(useValuePreviewMock)

document.title = 'Sanity Studio'
render(<DeskTitle resolvedPanes={mockPanes} />)
expect(document.title).toBe('Sanity Studio')
})

it('renders the correct title when the document pane has a title', () => {
const useEditStateMock = () => editState
const useValuePreviewMock = () => valuePreview
jest.spyOn(SANITY, 'useSchema').mockImplementationOnce(useSchemaMock)
jest.spyOn(SANITY, 'useEditState').mockImplementationOnce(useEditStateMock)
jest.spyOn(SANITY, 'unstable_useValuePreview').mockImplementationOnce(useValuePreviewMock)

document.title = 'Sanity Studio'
render(<DeskTitle resolvedPanes={mockPanes} />)
expect(document.title).toBe('Foo | My Desk Tool')
})
it('renders the correct title when the document is new', () => {
const useEditStateMock = () => ({...editState, draft: null})
const useValuePreviewMock = () => valuePreview
jest.spyOn(SANITY, 'useSchema').mockImplementationOnce(useSchemaMock)
jest.spyOn(SANITY, 'useEditState').mockImplementationOnce(useEditStateMock)
jest.spyOn(SANITY, 'unstable_useValuePreview').mockImplementationOnce(useValuePreviewMock)

document.title = 'Sanity Studio'
render(<DeskTitle resolvedPanes={mockPanes} />)
expect(document.title).toBe('New Author | My Desk Tool')
})
it('renders the correct title when the document is untitled', () => {
const useEditStateMock = () => editState
const useValuePreviewMock = () => ({
isLoading: false,
value: {title: ''},
})
jest.spyOn(SANITY, 'useSchema').mockImplementationOnce(useSchemaMock)
jest.spyOn(SANITY, 'useEditState').mockImplementationOnce(useEditStateMock)
jest.spyOn(SANITY, 'unstable_useValuePreview').mockImplementationOnce(useValuePreviewMock)

document.title = 'Sanity Studio'
render(<DeskTitle resolvedPanes={mockPanes} />)
expect(document.title).toBe('Untitled | My Desk Tool')
})
})
})
99 changes: 99 additions & 0 deletions packages/sanity/src/desk/components/deskTool/DeskTitle.tsx
@@ -0,0 +1,99 @@
import React, {useEffect} from 'react'
import {ObjectSchemaType} from '@sanity/types'
import {Panes} from '../../structureResolvers'
import {useDeskTool} from '../../useDeskTool'
import {LOADING_PANE} from '../../constants'
import {DocumentPaneNode} from '../../types'
import {useEditState, useSchema, unstable_useValuePreview as useValuePreview} from 'sanity'

interface DeskTitleProps {
resolvedPanes: Panes['resolvedPanes']
}

const DocumentTitle = (props: {documentId: string; documentType: string}) => {
const {documentId, documentType} = props
const editState = useEditState(documentId, documentType)
const schema = useSchema()
const isNewDocument = !editState?.published && !editState?.draft
const documentValue = editState?.draft || editState?.published
const schemaType = schema.get(documentType) as ObjectSchemaType | undefined

const {value, isLoading: previewValueIsLoading} = useValuePreview({
enabled: true,
schemaType,
value: documentValue,
})

const documentTitle = isNewDocument
? `New ${schemaType?.title || schemaType?.name}`
: value?.title || 'Untitled'

const settled = editState.ready && !previewValueIsLoading
const newTitle = useConstructDocumentTitle(documentTitle)
useEffect(() => {
if (!settled) return
// Set the title as the document title
document.title = newTitle
}, [documentTitle, settled, newTitle])

return null
}

const PassthroughTitle = (props: {title?: string}) => {
const {title} = props
const newTitle = useConstructDocumentTitle(title)
useEffect(() => {
// Set the title as the document title
document.title = newTitle
}, [newTitle, title])
return null
}

export const DeskTitle = (props: DeskTitleProps) => {
const {resolvedPanes} = props

if (!resolvedPanes?.length) return null

const lastPane = resolvedPanes[resolvedPanes.length - 1]

// If the last pane is loading, display the desk tool title only
if (isLoadingPane(lastPane)) {
return <PassthroughTitle />
}

// If the last pane is a document
if (isDocumentPane(lastPane)) {
// Passthrough the document pane's title, which may be defined in structure builder
if (lastPane?.title) {
return <PassthroughTitle title={lastPane.title} />
}

// Otherwise, display a `document.title` containing the resolved Sanity document title
return <DocumentTitle documentId={lastPane.options.id} documentType={lastPane.options.type} />
}

// Otherwise, display the last pane's title (if present)
return <PassthroughTitle title={lastPane?.title} />
}

/**
* Construct a pipe delimited title containing `activeTitle` (if applicable) and the base desk title.
*
* @param activeTitle - Title of the first segment
*
* @returns A pipe delimited title in the format `${activeTitle} | %BASE_DESK_TITLE%`
* or simply `%BASE_DESK_TITLE` if `activeTitle` is undefined.
*/
function useConstructDocumentTitle(activeTitle?: string) {
const deskToolBaseTitle = useDeskTool().structureContext.title
return [activeTitle, deskToolBaseTitle].filter((title) => title).join(' | ')
}

// Type guards
function isDocumentPane(pane: Panes['resolvedPanes'][number]): pane is DocumentPaneNode {
return pane !== LOADING_PANE && pane.type === 'document'
}

function isLoadingPane(pane: Panes['resolvedPanes'][number]): pane is typeof LOADING_PANE {
return pane === LOADING_PANE
}
2 changes: 2 additions & 0 deletions packages/sanity/src/desk/components/deskTool/DeskTool.tsx
Expand Up @@ -9,6 +9,7 @@ import {PaneNode} from '../../types'
import {PaneLayout} from '../pane'
import {useDeskTool} from '../../useDeskTool'
import {NoDocumentTypesScreen} from './NoDocumentTypesScreen'
import {DeskTitle} from './DeskTitle'
import {useSchema, _isCustomDocumentTypeDefinition} from 'sanity'
import {useRouterState} from 'sanity/router'

Expand Down Expand Up @@ -130,6 +131,7 @@ export const DeskTool = memo(function DeskTool({onPaneChange}: DeskToolProps) {
<LoadingPane paneKey="intent-resolver" />
)}
</StyledPaneLayout>
<DeskTitle resolvedPanes={resolvedPanes} />
<div data-portal="" ref={setPortalElement} />
</PortalProvider>
)
Expand Down
2 changes: 2 additions & 0 deletions packages/sanity/src/desk/deskTool.ts
Expand Up @@ -107,6 +107,8 @@ export const deskTool = definePlugin<DeskToolOptions | void>((options) => ({
(intent === 'create' && params.template),
)
},
// Controlled by sanity/src/desk/components/deskTool/DeskTitle.tsx
controlsDocumentTitle: true,
getIntentState,
options,
router,
Expand Down
Expand Up @@ -22,7 +22,7 @@ interface PaneData {
siblingIndex: number
}

interface Panes {
export interface Panes {
paneDataItems: PaneData[]
routerPanes: RouterPanes
resolvedPanes: (PaneNode | typeof LOADING_PANE)[]
Expand Down

2 comments on commit 2ace4f1

@vercel
Copy link

@vercel vercel bot commented on 2ace4f1 Sep 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

performance-studio – ./

performance-studio-git-next.sanity.build
performance-studio.sanity.build

@vercel
Copy link

@vercel vercel bot commented on 2ace4f1 Sep 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

test-studio – ./

test-studio.sanity.build
test-studio-git-next.sanity.build

Please sign in to comment.