Skip to content

Commit 7bf9995

Browse files
authored
fix(structure): live edit on draft documents (#7526)
* !TEMP(dev): add liveEdit to simple block * refactor(sanity): make livedit drafts readOnly * refactor(sanity): add banner explaining what needs to be done to get the live edit to work * refactor(sanity): add publish button to banner * feat(structure): allow discard draft in banner * test(e2e): add e2e test to check banner exists * refactor(e2e): add telemetry * refactor(structure): change names of telemetry events * refactor(structure): update banner text * refactor(structure): send in useDocumentPane info as props vs fetching them again * refactor(structure): update telemetry names and event properties
1 parent 7c814a8 commit 7bf9995

File tree

7 files changed

+169
-7
lines changed

7 files changed

+169
-7
lines changed

dev/test-studio/schema/standard/portableText/simpleBlock.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const myStringType = defineArrayMember({
3131
})
3232

3333
export default defineType({
34+
liveEdit: true,
3435
name: 'simpleBlock',
3536
title: 'Simple block',
3637
type: 'document',

packages/sanity/src/structure/i18n/resources.ts

+7
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ const structureLocaleStrings = defineLocalesResources('structure', {
9595
'banners.deleted-document-banner.text': 'This document has been deleted.',
9696
/** The text content for the deprecated document type banner */
9797
'banners.deprecated-document-type-banner.text': 'This document type has been deprecated.',
98+
/** The text for publish action for discarding the version */
99+
'banners.live-edit-draft-banner.discard.tooltip': 'Discard draft',
100+
/** The text for publish action for the draft banner */
101+
'banners.live-edit-draft-banner.publish.tooltip': 'Publish to continue editing',
102+
/** The text content for the live edit document when it's a draft */
103+
'banners.live-edit-draft-banner.text':
104+
'The type <strong>{{schemaType}}</strong> has <code>liveEdit</code> enabled, but a draft version of this document exists. Publish or discard the draft in order to continue live editing it.',
98105
/** The text for the permission check banner if the user only has one role, and it does not allow updating this document */
99106
'banners.permission-check-banner.missing-permission_create_one':
100107
'Your role <Roles/> does not have permissions to create this document.',

packages/sanity/src/structure/panes/document/DocumentPaneProvider.tsx

+13-7
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,9 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => {
517517
const createActionDisabled = isNonExistent && !isActionEnabled(schemaType!, 'create')
518518
const reconnecting = connectionState === 'reconnecting'
519519
const isLocked = editState.transactionSyncLock?.enabled
520+
// in cases where the document has drafts but the schema is live edit,
521+
// there is a risk of data loss, so we disable editing in this case
522+
const isLiveEditAndDraft = Boolean(liveEdit && editState.draft)
520523

521524
return (
522525
!ready ||
@@ -527,19 +530,22 @@ export const DocumentPaneProvider = memo((props: DocumentPaneProviderProps) => {
527530
reconnecting ||
528531
isLocked ||
529532
isDeleting ||
530-
isDeleted
533+
isDeleted ||
534+
isLiveEditAndDraft
531535
)
532536
}, [
533-
connectionState,
534-
editState.transactionSyncLock,
535-
isNonExistent,
536-
isDeleted,
537-
isDeleting,
538537
isPermissionsLoading,
539538
permissions?.granted,
539+
schemaType,
540+
isNonExistent,
541+
connectionState,
542+
editState.transactionSyncLock?.enabled,
543+
editState.draft,
544+
liveEdit,
540545
ready,
541546
revTime,
542-
schemaType,
547+
isDeleting,
548+
isDeleted,
543549
])
544550

545551
const formState = useFormState({

packages/sanity/src/structure/panes/document/documentPanel/DocumentPanel.tsx

+12
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {ScrollContainer, useTimelineSelector, VirtualizerScrollInstanceProvider}
44
import {css, styled} from 'styled-components'
55

66
import {PaneContent, usePane, usePaneLayout} from '../../../components'
7+
import {isLiveEditEnabled} from '../../../components/paneItem/helpers'
78
import {useStructureTool} from '../../../useStructureTool'
89
import {DocumentInspectorPanel} from '../documentInspector'
910
import {InspectDialog} from '../inspectDialog'
@@ -14,6 +15,7 @@ import {
1415
PermissionCheckBanner,
1516
ReferenceChangedBanner,
1617
} from './banners'
18+
import {DraftLiveEditBanner} from './banners/DraftLiveEditBanner'
1719
import {FormView} from './documentViews'
1820

1921
interface DocumentPanelProps {
@@ -117,6 +119,8 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) {
117119
(state) => state.lastNonDeletedRevId,
118120
)
119121

122+
const isLiveEdit = isLiveEditEnabled(schemaType)
123+
120124
// Scroll to top as `documentId` changes
121125
useEffect(() => {
122126
if (!documentScrollElement?.scrollTo) return
@@ -150,6 +154,14 @@ export const DocumentPanel = function DocumentPanel(props: DocumentPanelProps) {
150154
scrollElement={documentScrollElement}
151155
containerElement={formContainerElement}
152156
>
157+
{activeView.type === 'form' && isLiveEdit && ready && (
158+
<DraftLiveEditBanner
159+
displayed={displayed}
160+
documentId={documentId}
161+
schemaType={schemaType}
162+
/>
163+
)}
164+
153165
{activeView.type === 'form' && !isPermissionsLoading && ready && (
154166
<>
155167
<PermissionCheckBanner
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {type SanityDocument} from '@sanity/client'
2+
import {ErrorOutlineIcon} from '@sanity/icons'
3+
import {useTelemetry} from '@sanity/telemetry/react'
4+
import {Flex, Text} from '@sanity/ui'
5+
import {useCallback, useEffect, useState} from 'react'
6+
import {
7+
isDraftId,
8+
type ObjectSchemaType,
9+
Translate,
10+
useDocumentOperation,
11+
useTranslation,
12+
} from 'sanity'
13+
14+
import {Button} from '../../../../../ui-components'
15+
import {structureLocaleNamespace} from '../../../../i18n'
16+
import {ResolvedLiveEdit} from './__telemetry__/DraftLiveEditBanner.telemetry'
17+
import {Banner} from './Banner'
18+
19+
interface DraftLiveEditBannerProps {
20+
displayed: Partial<SanityDocument> | null
21+
documentId: string
22+
schemaType: ObjectSchemaType
23+
}
24+
25+
export function DraftLiveEditBanner({
26+
displayed,
27+
documentId,
28+
schemaType,
29+
}: DraftLiveEditBannerProps): JSX.Element | null {
30+
const {t} = useTranslation(structureLocaleNamespace)
31+
const [isPublishing, setPublishing] = useState(false)
32+
const [isDiscarding, setDiscarding] = useState(false)
33+
const telemetry = useTelemetry()
34+
35+
const {publish, discardChanges} = useDocumentOperation(documentId, displayed?._type || '')
36+
37+
const handlePublish = useCallback(() => {
38+
publish.execute()
39+
setPublishing(true)
40+
telemetry.log(ResolvedLiveEdit, {liveEditResolveType: 'publish'})
41+
}, [publish, telemetry])
42+
43+
const handleDiscard = useCallback(() => {
44+
discardChanges.execute()
45+
setDiscarding(true)
46+
telemetry.log(ResolvedLiveEdit, {liveEditResolveType: 'discard'})
47+
}, [discardChanges, telemetry])
48+
49+
useEffect(() => {
50+
return () => {
51+
setPublishing(false)
52+
setDiscarding(false)
53+
}
54+
})
55+
56+
if (displayed && displayed._id && !isDraftId(displayed._id)) {
57+
return null
58+
}
59+
60+
return (
61+
<Banner
62+
content={
63+
<Flex align="center" justify="space-between" gap={1}>
64+
<Text size={1} weight="medium">
65+
<Translate
66+
t={t}
67+
i18nKey={'banners.live-edit-draft-banner.text'}
68+
values={{schemaType: schemaType.title}}
69+
/>
70+
</Text>
71+
<Button
72+
onClick={handlePublish}
73+
text={t('action.publish.live-edit.label')}
74+
tooltipProps={{content: t('banners.live-edit-draft-banner.publish.tooltip')}}
75+
loading={isPublishing}
76+
/>
77+
78+
<Button
79+
onClick={handleDiscard}
80+
text={t('banners.live-edit-draft-banner.discard.tooltip')}
81+
tooltipProps={{content: t('banners.live-edit-draft-banner.discard.tooltip')}}
82+
loading={isDiscarding}
83+
/>
84+
</Flex>
85+
}
86+
data-testid="live-edit-type-banner"
87+
icon={ErrorOutlineIcon}
88+
/>
89+
)
90+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {defineEvent} from '@sanity/telemetry'
2+
3+
interface TypeInfo {
4+
liveEditResolveType: 'publish' | 'discard'
5+
}
6+
7+
/**
8+
* When a draft in a live edit document is published
9+
* @internal
10+
*/
11+
export const ResolvedLiveEdit = defineEvent<TypeInfo>({
12+
name: 'Resolved LiveEdit Draft',
13+
version: 1,
14+
description: 'User resolved a draft of a live edit document to continue editing',
15+
})
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* eslint-disable max-nested-callbacks */
2+
import {expect, test} from '@playwright/test'
3+
4+
import {createUniqueDocument, withDefaultClient} from '../../helpers'
5+
6+
withDefaultClient((context) => {
7+
test.describe('sanity/structure: document pane', () => {
8+
test('on live edit document with a draft, a banner should appear', async ({page}) => {
9+
// create published document
10+
const uniqueDoc = await createUniqueDocument(context.client, {_type: 'playlist'})
11+
const id = uniqueDoc._id!
12+
13+
// create draft document
14+
await createUniqueDocument(context.client, {
15+
_type: 'playlist',
16+
_id: `drafts.${id}`,
17+
name: 'Edited by e2e test runner',
18+
})
19+
20+
await page.goto(`/test/content/playlist;${id}`)
21+
22+
await expect(page.getByTestId('document-panel-scroller')).toBeAttached()
23+
await expect(page.getByTestId('string-input')).toBeAttached()
24+
25+
// checks that inputs are set to read only
26+
await expect(await page.getByTestId('string-input')).toHaveAttribute('readonly', '')
27+
// checks that the banner is visible
28+
await expect(page.getByTestId('live-edit-type-banner')).toBeVisible()
29+
})
30+
})
31+
})

0 commit comments

Comments
 (0)