diff --git a/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts index 2888a123dff..029e2630803 100644 --- a/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts +++ b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts @@ -20,6 +20,13 @@ export interface OriginInfo { origin: 'structure' | 'release-plugin' } +export interface RevertInfo { + /** + * determined whether reverting a release created a new staged release, or immediately reverted + */ + revertType: 'immediate' | 'staged' +} + /** * When a document (version) is successfully added to a release * @internal @@ -101,3 +108,12 @@ export const UnarchivedRelease = defineEvent({ version: 1, description: 'User unarchived a release', }) + +/** When a release is successfully reverted + * @internal + */ +export const RevertRelease = defineEvent({ + name: 'Revert release', + version: 1, + description: 'User reverted a release', +}) diff --git a/packages/sanity/src/core/releases/i18n/resources.ts b/packages/sanity/src/core/releases/i18n/resources.ts index d2745c59ec6..40fa620f7aa 100644 --- a/packages/sanity/src/core/releases/i18n/resources.ts +++ b/packages/sanity/src/core/releases/i18n/resources.ts @@ -14,6 +14,8 @@ const releasesLocaleStrings = { 'action.archived': 'Archived', /** Action text for comparing document versions */ 'action.compare-versions': 'Compare versions', + /** Action text for reverting a release by creating a new release */ + 'action.create-revert-release': 'Stage in new release', /** Action text for deleting a release */ 'action.delete-release': 'Delete release', /** Action text for editing a release */ @@ -32,8 +34,12 @@ const releasesLocaleStrings = { 'action.publish-all-documents': 'Publish all documents', /** Text for the review changes button in release tool */ 'action.review': 'Review changes', + /** Action text for reverting a release */ + 'action.revert': 'Revert release', /** Text for the summary button in release tool */ 'actions.summary': 'Summary', + /** Action text for reverting a release immediately without staging changes */ + 'action.immediate-revert-release': 'Revert now', /** Label for unarchiving a release */ 'action.unarchive': 'Unarchive release', /** Header for the dialog confirming the archive of a release */ @@ -185,6 +191,29 @@ const releasesLocaleStrings = { /** Label for when documents in release have validation errors */ 'publish-dialog.validation.error': 'Some documents have validation errors', + /** Description for the review changes button in release tool */ + 'review.description': 'Add documents to this release to review changes', + /** Text for when a document is edited */ + 'review.edited': 'Edited ', + /** Description for the dialog confirming the revert of a release with multiple documents */ + 'revert-dialog.confirm-revert-description_one': + 'This will revert {{releaseDocumentsLength}} document version.', + /** Description for the dialog confirming the revert of a release with multiple documents */ + 'revert-dialog.confirm-revert-description_other': + 'This will revert {{releaseDocumentsLength}} document versions.', + /** Title for the dialog confirming the revert of a release */ + 'revert-dialog.confirm-revert.title': "Are you sure you want to revert the '{{title}}' release?", + /** Checkbox label to confirm whether to create a staged release for revert or immediately revert */ + 'revert-dialog.confirm-revert.stage-revert-checkbox-label': + 'Stage revert actions in a new release', + /** Warning card text for when immediately revert a release with history */ + 'revert-dialog.confirm-revert.warning-card': + 'Changes were made to documents in this release after they were published. Reverting will overwrite these changes.', + /** Title of a reverted release */ + 'revert-release.title': 'Reverting "{{title}}"', + /** Description of a reverted release */ + 'revert-release.description': 'Revert changes to document versions in "{{title}}".', + /** Title o unschedule release dialog */ 'schedule-button.tooltip': 'Are you sure you want to unschedule the release?', @@ -204,7 +233,7 @@ const releasesLocaleStrings = { "The '{{title}}' release and its document will be published on the selected date.", /** Description for the dialog confirming the publish of a release with multiple documents */ 'schedule-dialog.confirm-description_other': - 'The {{title}} release and its {{count}} document versions will be scheduled for publishing.', + 'The {{title}} release and its {{count}} document versions will be scheduled for publishing.', /** Description for the confirm button for scheduling a release */ 'schedule-dialog.confirm-button': 'Yes, schedule for publishing', @@ -218,11 +247,6 @@ const releasesLocaleStrings = { 'unschedule-dialog.confirm-description': 'The release will no longer be published on the scheduled date', - /** Description for the review changes button in release tool */ - 'review.description': 'Add documents to this release to review changes', - /** Text for when a document is edited */ - 'review.edited': 'Edited ', - /** Placeholder for search of documents in a release */ 'search-documents-placeholder': 'Search documents', /** Text for when the release was created */ @@ -282,6 +306,14 @@ const releasesLocaleStrings = { 'toast.unschedule.success': "The '{{title}}' release was unscheduled.", /** Text for tooltip when a release has been scheduled */ 'type-picker.tooltip.scheduled': 'The release is scheduled, unschedule it to change type', + /** Text for toast when release failed to revert */ + 'toast.revert.error': 'Failed to revert release: {{error}}', + /** Text for toast when release has been reverted immediately */ + 'toast.immediate-revert.success': "The '{{title}}' release was successfully reverted", + /** Text for toast when release has reverted release successfully staged */ + 'toast.revert-stage.success': "Revert release for '{{title}}' was successfully created. ", + /** Link text for toast link to the generated revert release */ + 'toast.revert-stage.success-link': 'View revert release', /** Title for the dialog confirming the unpublish of a release */ 'unpublish-dialog.header': 'Are you sure you want to unpublish this document when releasing?', diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts index 4411b11e58b..dcb169cf213 100644 --- a/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/createReleaseOperationsStore.mock.ts @@ -16,6 +16,7 @@ export const createReleaseOperationsStoreReturn: Mocked unschedule: vi.fn(), updateRelease: vi.fn(), deleteRelease: vi.fn(), + revertRelease: vi.fn(), unpublishVersion: vi.fn(), } diff --git a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts index 01d0abaa10d..9b7ce14aeca 100644 --- a/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts +++ b/packages/sanity/src/core/releases/store/__tests__/__mocks/useReleaseOperations.mock.ts @@ -14,6 +14,7 @@ export const useReleaseOperationsMockReturn: Mocked = { unschedule: vi.fn(), updateRelease: vi.fn(), deleteRelease: vi.fn(), + revertRelease: vi.fn(), unpublishVersion: vi.fn(), } diff --git a/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts b/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts new file mode 100644 index 00000000000..b7993c8785d --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/createReleaseOperationsStore.test.ts @@ -0,0 +1,355 @@ +import {type ReleaseDocument} from 'sanity' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {type RevertDocument} from '../../tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates' +import {createReleaseOperationsStore} from '../createReleaseOperationStore' + +describe('createReleaseOperationsStore', () => { + let mockClient: any + + beforeEach(() => { + mockClient = { + config: vi.fn().mockReturnValue({dataset: 'test-dataset'}), + request: vi.fn().mockResolvedValue(undefined), + create: vi.fn().mockResolvedValue(undefined), + getDocument: vi.fn(), + } + }) + + const createStore = () => createReleaseOperationsStore({client: mockClient}) + + it('should create a release', async () => { + const store = createStore() + const release = {_id: '_.releases.release-id', metadata: {title: 'Test Release'}} + await store.createRelease(release) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: 'release-id', + metadata: release.metadata, + }, + ], + }, + }) + }) + + it('should update a release', async () => { + const store = createStore() + const release = {_id: '_.releases.release-id', metadata: {title: 'Updated Title'}} + await store.updateRelease(release) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.edit', + releaseId: 'release-id', + patch: { + set: {metadata: release.metadata}, + unset: [], + }, + }, + ], + }, + }) + }) + + it('should publish a release using new publish', async () => { + const store = createStore() + await store.publishRelease('_.releases.release-id', true) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish2', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should publish a release using stable publish', async () => { + const store = createStore() + await store.publishRelease('_.releases.release-id', false) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should schedule a release', async () => { + const store = createStore() + const date = new Date('2024-01-01T00:00:00Z') + await store.schedule('_.releases.release-id', date) + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.schedule', + releaseId: 'release-id', + publishAt: date.toISOString(), + }, + ], + }, + }) + }) + + it('should unschedule a release', async () => { + const store = createStore() + await store.unschedule('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.unschedule', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should archive a release', async () => { + const store = createStore() + await store.archive('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.archive', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should unarchive a release', async () => { + const store = createStore() + await store.unarchive('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.unarchive', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + it('should delete a release', async () => { + const store = createStore() + await store.deleteRelease('_.releases.release-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.delete', + releaseId: 'release-id', + }, + ], + }, + }) + }) + + describe('revertRelease', () => { + let store: ReturnType + const revertReleaseId: string = 'revert-release-id' + const revertReleaseDocumentId: string = '_.releases.revert-release-id' + let releaseDocuments: RevertDocument[] + let releaseMetadata: ReleaseDocument['metadata'] + + beforeEach(() => { + store = createStore() + releaseDocuments = [{_id: 'doc1'}, {_id: 'doc2'}] as RevertDocument[] + releaseMetadata = { + title: 'Revert Release', + description: 'A reverted release', + } as ReleaseDocument['metadata'] + }) + + it('should create a new release and publish immediately when revertType is "immediate"', async () => { + await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'immediate', + ) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: revertReleaseId, + metadata: {...releaseMetadata, releaseType: 'asap'}, + }, + ], + }, + }) + + expect(mockClient.create).toHaveBeenNthCalledWith(1, { + _id: `versions.${revertReleaseId}.doc1`, + }) + expect(mockClient.create).toHaveBeenNthCalledWith(2, { + _id: `versions.${revertReleaseId}.doc2`, + }) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.publish', + releaseId: 'revert-release-id', + }, + ], + }, + }) + }) + + it('should create a new release without publishing when revertType is "staged"', async () => { + await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'staged', + ) + + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.release.create', + releaseId: revertReleaseId, + metadata: {...releaseMetadata, releaseType: 'asap'}, + }, + ], + }, + }) + + expect(mockClient.create).toHaveBeenCalledTimes(2) + expect(mockClient.request).toHaveBeenCalledTimes(1) + }) + + it('should fail if a document does not exist and no initial value is provided', async () => { + mockClient.getDocument.mockResolvedValueOnce(null) // Simulate a missing document + + await expect( + store.revertRelease( + revertReleaseDocumentId, + [{_id: 'missing-doc'}] as RevertDocument[], + releaseMetadata, + 'staged', + ), + ).resolves.toBeUndefined() + }) + + it('should handle partial failure gracefully when creating versions', async () => { + mockClient.create.mockRejectedValueOnce(new Error('Failed to create version')) + + const result = await store.revertRelease( + revertReleaseDocumentId, + releaseDocuments, + releaseMetadata, + 'staged', + ) + + expect(result).toBeUndefined() + expect(mockClient.create).toHaveBeenCalledTimes(2) + expect(mockClient.create).toHaveBeenNthCalledWith(1, { + _id: `versions.${revertReleaseId}.doc1`, + }) + expect(mockClient.create).toHaveBeenNthCalledWith(2, { + _id: `versions.${revertReleaseId}.doc2`, + }) + }) + + it('should throw an error if creating the release fails', async () => { + mockClient.request.mockRejectedValueOnce(new Error('Failed to create release')) + + await expect( + store.revertRelease(revertReleaseDocumentId, releaseDocuments, releaseMetadata, 'staged'), + ).rejects.toThrow('Failed to create release') + }) + }) + + it('should create a version of a document', async () => { + const store = createStore() + mockClient.getDocument.mockResolvedValue({_id: 'doc-id', data: 'example'}) + await store.createVersion('release-id', 'doc-id', {newData: 'value'}) + expect(mockClient.create).toHaveBeenCalledWith({ + _id: `versions.release-id.doc-id`, + data: 'example', + newData: 'value', + }) + }) + + it('should discard a version of a document', async () => { + const store = createStore() + await store.discardVersion('release-id', 'doc-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.document.discard', + draftId: 'versions.release-id.doc-id', + }, + ], + }, + }) + }) + + it('should unpublish a version of a document', async () => { + const store = createStore() + await store.unpublishVersion('doc-id') + expect(mockClient.request).toHaveBeenCalledWith({ + uri: '/data/actions/test-dataset', + method: 'POST', + body: { + actions: [ + { + actionType: 'sanity.action.document.version.unpublish', + draftId: 'doc-id', + publishedId: `doc-id`, + }, + ], + }, + }) + }) +}) diff --git a/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts index 6cdd6889c7e..cec8509b5a5 100644 --- a/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts +++ b/packages/sanity/src/core/releases/store/createReleaseOperationStore.ts @@ -7,6 +7,7 @@ import { import {getPublishedId, getVersionId} from '../../util' import {getReleaseIdFromReleaseDocumentId, type ReleaseDocument} from '../index' +import {type RevertDocument} from '../tool/components/releaseCTAButtons/ReleaseRevertButton/useDocumentRevertStates' import {type EditableReleaseDocument} from './types' export interface ReleaseOperationsStore { @@ -19,6 +20,12 @@ export interface ReleaseOperationsStore { updateRelease: (release: EditableReleaseDocument) => Promise createRelease: (release: EditableReleaseDocument) => Promise deleteRelease: (releaseId: string) => Promise + revertRelease: ( + revertReleaseId: string, + documents: RevertDocument[], + releaseMetadata: ReleaseDocument['metadata'], + revertType: 'staged' | 'immediate', + ) => Promise createVersion: ( releaseId: string, documentId: string, @@ -127,7 +134,8 @@ export function createReleaseOperationsStore(options: { } const versionDocument = { - ...(document || initialValue || {}), + ...(document || {}), + ...(initialValue || {}), _id: getVersionId(documentId, releaseId), } as IdentifiedSanityDocumentStub @@ -159,6 +167,35 @@ export function createReleaseOperationsStore(options: { }, ]) + const handleRevertRelease = async ( + revertReleaseId: string, + releaseDocuments: RevertDocument[], + releaseMetadata: ReleaseDocument['metadata'], + revertType: 'staged' | 'immediate', + ) => { + await handleCreateRelease({ + _id: revertReleaseId, + metadata: { + title: releaseMetadata.title, + description: releaseMetadata.description, + releaseType: 'asap', + }, + }) + await Promise.allSettled( + releaseDocuments.map((document) => + handleCreateVersion( + getReleaseIdFromReleaseDocumentId(revertReleaseId), + document._id, + document, + ), + ), + ) + + if (revertType === 'immediate') { + await handlePublishRelease(revertReleaseId) + } + } + return { archive: handleArchiveRelease, unarchive: handleUnarchiveRelease, @@ -168,6 +205,7 @@ export function createReleaseOperationsStore(options: { updateRelease: handleUpdateRelease, publishRelease: handlePublishRelease, deleteRelease: handleDeleteRelease, + revertRelease: handleRevertRelease, createVersion: handleCreateVersion, discardVersion: handleDiscardVersion, unpublishVersion: handleUnpublishVersion, diff --git a/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx new file mode 100644 index 00000000000..825cc07203f --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/releaseCTAButtons/ReleaseRevertButton/ReleaseRevertButton.tsx @@ -0,0 +1,238 @@ +import {RestoreIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Box, Card, Checkbox, Flex, Text, useToast} from '@sanity/ui' +import {useCallback, useState} from 'react' +import {useRouter} from 'sanity/router' + +import {Button} from '../../../../../../ui-components/button/Button' +import {Dialog} from '../../../../../../ui-components/dialog' +import {Translate, useTranslation} from '../../../../../i18n' +import {RevertRelease} from '../../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../../i18n' +import {type ReleaseDocument} from '../../../../store/types' +import {useReleaseOperations} from '../../../../store/useReleaseOperations' +import {createReleaseId} from '../../../../util/createReleaseId' +import {getReleaseIdFromReleaseDocumentId} from '../../../../util/getReleaseIdFromReleaseDocumentId' +import {type DocumentInRelease} from '../../../detail/useBundleDocuments' +import {useDocumentRevertStates} from './useDocumentRevertStates' +import {usePostPublishTransactions} from './usePostPublishTransactions' + +interface ReleasePublishAllButtonProps { + release: ReleaseDocument + documents: DocumentInRelease[] + disabled?: boolean +} + +type RevertReleaseStatus = 'idle' | 'confirm' | 'reverting' + +const ConfirmReleaseDialog = ({ + revertReleaseStatus, + documents, + setRevertReleaseStatus, + release, +}: { + revertReleaseStatus: RevertReleaseStatus + documents: DocumentInRelease[] + setRevertReleaseStatus: (status: RevertReleaseStatus) => void + release: ReleaseDocument +}) => { + const {t} = useTranslation(releasesLocaleNamespace) + const hasPostPublishTransactions = usePostPublishTransactions(documents) + const getDocumentRevertStates = useDocumentRevertStates(documents) + const [stageNewRevertRelease, setStageNewRevertRelease] = useState(true) + const toast = useToast() + const telemetry = useTelemetry() + const {revertRelease} = useReleaseOperations() + const router = useRouter() + + const navigateToRevertRelease = useCallback( + (revertReleaseId: string) => () => + router.navigate({releaseId: getReleaseIdFromReleaseDocumentId(revertReleaseId)}), + [router], + ) + + const handleRevertRelease = useCallback(async () => { + setRevertReleaseStatus('reverting') + const documentRevertStates = await getDocumentRevertStates() + + const revertReleaseId = createReleaseId() + + try { + if (!documentRevertStates) { + throw new Error('Unable to find documents to revert') + } + + await revertRelease( + revertReleaseId, + documentRevertStates, + { + title: t('revert-release.title', {title: release.metadata.title}), + description: t('revert-release.description', {title: release.metadata.title}), + releaseType: 'asap', + }, + stageNewRevertRelease ? 'staged' : 'immediate', + ) + + if (stageNewRevertRelease) { + telemetry.log(RevertRelease, {revertType: 'staged'}) + toast.push({ + closable: true, + status: 'success', + title: ( + + ( + + {t('toast.revert-stage.success-link')} + + ), + }} + t={t} + i18nKey="toast.revert-stage.success" + values={{title: release.metadata.title}} + /> + + ), + }) + } else { + telemetry.log(RevertRelease, {revertType: 'immediate'}) + + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } + } catch (revertError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(revertError) + } finally { + setRevertReleaseStatus('idle') + } + }, [ + setRevertReleaseStatus, + getDocumentRevertStates, + revertRelease, + t, + release.metadata.title, + stageNewRevertRelease, + telemetry, + toast, + navigateToRevertRelease, + ]) + + const description = + documents.length > 1 + ? 'revert-dialog.confirm-revert-description_other' + : 'revert-dialog.confirm-revert-description_one' + + return ( + setRevertReleaseStatus('idle')} + footer={{ + confirmButton: { + text: t( + stageNewRevertRelease + ? 'action.create-revert-release' + : 'action.immediate-revert-release', + ), + tone: 'positive', + onClick: handleRevertRelease, + loading: revertReleaseStatus === 'reverting', + disabled: revertReleaseStatus === 'reverting', + }, + }} + > + + { + + } + + + setStageNewRevertRelease((current) => !current)} + id="stage-release" + style={{display: 'block'}} + checked={stageNewRevertRelease} + /> + + + + + + + {hasPostPublishTransactions && !stageNewRevertRelease && ( + + + {t('revert-dialog.confirm-revert.warning-card')} + + + )} + + ) +} + +export const ReleaseRevertButton = ({ + release, + documents, + disabled, +}: ReleasePublishAllButtonProps) => { + const {t} = useTranslation(releasesLocaleNamespace) + const [revertReleaseStatus, setRevertReleaseStatus] = useState('idle') + + return ( + <> +