From 2ace4f1f6d83866046d95ac8f48b692afa935430 Mon Sep 17 00:00:00 2001 From: Pedro Bonamin <46196328+pedrobonamin@users.noreply.github.com> Date: Wed, 6 Sep 2023 13:28:22 +0200 Subject: [PATCH] feat(titles): Update titles inside desk tool (#4887) * 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 --- packages/sanity/src/core/config/types.ts | 5 + .../sanity/src/core/studio/StudioLayout.tsx | 8 +- .../components/deskTool/DeskTitle.test.tsx | 170 ++++++++++++++++++ .../desk/components/deskTool/DeskTitle.tsx | 99 ++++++++++ .../src/desk/components/deskTool/DeskTool.tsx | 2 + packages/sanity/src/desk/deskTool.ts | 2 + .../structureResolvers/useResolvedPanes.ts | 2 +- 7 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 packages/sanity/src/desk/components/deskTool/DeskTitle.test.tsx create mode 100644 packages/sanity/src/desk/components/deskTool/DeskTitle.tsx diff --git a/packages/sanity/src/core/config/types.ts b/packages/sanity/src/core/config/types.ts index adcb9bb78da..9d2c6b64f35 100644 --- a/packages/sanity/src/core/config/types.ts +++ b/packages/sanity/src/core/config/types.ts @@ -141,6 +141,11 @@ export interface Tool { */ title: string + /** + * Determines whether the tool will control the `document.title`. + */ + controlsDocumentTitle?: boolean + /** * Gets the state for the given intent. * diff --git a/packages/sanity/src/core/studio/StudioLayout.tsx b/packages/sanity/src/core/studio/StudioLayout.tsx index 25497e6ffef..3266ad4f996 100644 --- a/packages/sanity/src/core/studio/StudioLayout.tsx +++ b/packages/sanity/src/core/studio/StudioLayout.tsx @@ -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) diff --git a/packages/sanity/src/desk/components/deskTool/DeskTitle.test.tsx b/packages/sanity/src/desk/components/deskTool/DeskTitle.test.tsx new file mode 100644 index 00000000000..449cce74daf --- /dev/null +++ b/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() + expect(document.title).toBe('Content | My Desk Tool') + }) + it('renders the correct title when an inner pane is open', () => { + render() + expect(document.title).toBe('Author | My Desk Tool') + }) + it('renders the correct title when the document pane has a title', () => { + render() + expect(document.title).toBe('Authors created | My Desk Tool') + }) + it('should not update the title if no panes are available', () => { + render() + 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() + 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() + 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() + 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() + expect(document.title).toBe('Untitled | My Desk Tool') + }) + }) +}) diff --git a/packages/sanity/src/desk/components/deskTool/DeskTitle.tsx b/packages/sanity/src/desk/components/deskTool/DeskTitle.tsx new file mode 100644 index 00000000000..48e7d5683c3 --- /dev/null +++ b/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 + } + + // 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 + } + + // Otherwise, display a `document.title` containing the resolved Sanity document title + return + } + + // Otherwise, display the last pane's title (if present) + return +} + +/** + * 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 +} diff --git a/packages/sanity/src/desk/components/deskTool/DeskTool.tsx b/packages/sanity/src/desk/components/deskTool/DeskTool.tsx index 4a92be220e6..c81db16815e 100644 --- a/packages/sanity/src/desk/components/deskTool/DeskTool.tsx +++ b/packages/sanity/src/desk/components/deskTool/DeskTool.tsx @@ -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' @@ -130,6 +131,7 @@ export const DeskTool = memo(function DeskTool({onPaneChange}: DeskToolProps) { )} +
) diff --git a/packages/sanity/src/desk/deskTool.ts b/packages/sanity/src/desk/deskTool.ts index 8ae9a53e33b..896451f37db 100644 --- a/packages/sanity/src/desk/deskTool.ts +++ b/packages/sanity/src/desk/deskTool.ts @@ -107,6 +107,8 @@ export const deskTool = definePlugin((options) => ({ (intent === 'create' && params.template), ) }, + // Controlled by sanity/src/desk/components/deskTool/DeskTitle.tsx + controlsDocumentTitle: true, getIntentState, options, router, diff --git a/packages/sanity/src/desk/structureResolvers/useResolvedPanes.ts b/packages/sanity/src/desk/structureResolvers/useResolvedPanes.ts index fc56b0d856c..088c3fcb82c 100644 --- a/packages/sanity/src/desk/structureResolvers/useResolvedPanes.ts +++ b/packages/sanity/src/desk/structureResolvers/useResolvedPanes.ts @@ -22,7 +22,7 @@ interface PaneData { siblingIndex: number } -interface Panes { +export interface Panes { paneDataItems: PaneData[] routerPanes: RouterPanes resolvedPanes: (PaneNode | typeof LOADING_PANE)[]