diff --git a/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts index 35b678ed4de..b23fad4d8d1 100644 --- a/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts +++ b/packages/sanity/src/core/releases/__telemetry__/releases.telemetry.ts @@ -65,6 +65,24 @@ export const PublishedRelease = defineEvent({ description: 'User published a release', }) +/** When a release is successfully scheduled + * @internal + */ +export const ScheduledRelease = defineEvent({ + name: 'Schedule release', + version: 1, + description: 'User scheduled a release', +}) + +/** When a release is successfully scheduled + * @internal + */ +export const UnscheduledRelease = defineEvent({ + name: 'Unschedule release', + version: 1, + description: 'User unscheduled a release', +}) + /** When a release is successfully archived * @internal */ diff --git a/packages/sanity/src/core/releases/components/dialog/ReleaseDetailsDialog.tsx b/packages/sanity/src/core/releases/components/dialog/ReleaseDetailsDialog.tsx index d89bf7fe058..6d047ca1d61 100644 --- a/packages/sanity/src/core/releases/components/dialog/ReleaseDetailsDialog.tsx +++ b/packages/sanity/src/core/releases/components/dialog/ReleaseDetailsDialog.tsx @@ -50,13 +50,6 @@ export function ReleaseDetailsDialog(props: ReleaseDetailsDialogProps): JSX.Elem // TODO MAKE SURE THIS IS HOW WE WANT TO DO THIS const {setPerspective} = usePerspective() - const submit = useCallback( - (formValue: EditableReleaseDocument) => { - return formAction === 'edit' ? updateRelease(formValue) : createRelease(formValue) - }, - [createRelease, formAction, updateRelease], - ) - const handleOnSubmit = useCallback( async (event: FormEvent) => { try { @@ -67,7 +60,8 @@ export function ReleaseDetailsDialog(props: ReleaseDetailsDialogProps): JSX.Elem ...value, metadata: {...value.metadata, title: value.metadata?.title?.trim()}, } - await submit(submitValue) + const action = formAction === 'edit' ? updateRelease : createRelease + await action(submitValue) if (formAction === 'create') { setPerspective(value._id) telemetry.log(CreatedRelease, {origin}) @@ -86,7 +80,17 @@ export function ReleaseDetailsDialog(props: ReleaseDetailsDialogProps): JSX.Elem onSubmit() } }, - [value, submit, formAction, setPerspective, telemetry, origin, toast, onSubmit], + [ + value, + formAction, + updateRelease, + createRelease, + setPerspective, + telemetry, + origin, + toast, + onSubmit, + ], ) const handleOnChange = useCallback((changedValue: EditableReleaseDocument) => { diff --git a/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseDetailsDialog.test.tsx b/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseDetailsDialog.test.tsx index 7fca606df3a..f6de6513291 100644 --- a/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseDetailsDialog.test.tsx +++ b/packages/sanity/src/core/releases/components/dialog/__tests__/ReleaseDetailsDialog.test.tsx @@ -71,7 +71,7 @@ describe('ReleaseDetailsDialog', () => { expect(onCancelMock).toHaveBeenCalled() }) - it('should call createRelease, setPerspective, and onCreate when form is submitted with a valid slug', async () => { + it('should call createRelease, setPerspective, and onCreate when form is submitted', async () => { const value: Partial = { metadata: { title: 'Bundle 1', @@ -89,7 +89,7 @@ describe('ReleaseDetailsDialog', () => { expect(useReleaseOperations().createRelease).toHaveBeenCalledWith( expect.objectContaining({ - _id: expect.stringMatching(/system-tmp-releases\.r\w{8}$/), + _id: expect.stringMatching(/_\.releases\.r\w{8}$/), ...value, }), ) @@ -98,7 +98,7 @@ describe('ReleaseDetailsDialog', () => { expect(usePerspective().setPerspective).toHaveBeenCalledOnce() expect(usePerspective().setPerspective).toHaveBeenCalledWith( - expect.stringMatching(/system-tmp-releases\.r\w{8}$/), + expect.stringMatching(/_\.releases\.r\w{8}$/), ) expect(onSubmitMock).toHaveBeenCalled() }) @@ -111,7 +111,7 @@ describe('ReleaseDetailsDialog', () => { _id: 'existing-release', name: 'existing', state: 'active', - _type: 'system-tmp.release', + _type: 'system.release', _createdAt: '2024-07-02T11:37:51Z', _updatedAt: '2024-07-12T10:39:32Z', createdBy: '123', diff --git a/packages/sanity/src/core/releases/i18n/resources.ts b/packages/sanity/src/core/releases/i18n/resources.ts index 32198d707a9..d5e1aaec1fe 100644 --- a/packages/sanity/src/core/releases/i18n/resources.ts +++ b/packages/sanity/src/core/releases/i18n/resources.ts @@ -20,6 +20,10 @@ const releasesLocaleStrings = { 'action.open': 'Open', /** Action text for publishing a release */ 'action.publish': 'Publish', + /** Action text for scheduling a release */ + 'action.schedule': 'Schedule for publishing', + /** Action text for scheduling a release */ + 'action.unschedule': 'Unschedule', /** Action text for publishing all documents in a release (and the release itself) */ 'action.publish-all': 'Publish all', /** Text for the review changes button in release tool */ @@ -122,6 +126,39 @@ const releasesLocaleStrings = { /** Label for when documents in release have validation errors */ 'publish-dialog.validation.error': 'Some documents have validation errors', + /** Title o unschedule release dialog */ + 'schedule-button.tooltip': 'Are you sure you want to unschedule the release?', + + /** Schedule release button tooltip when validation is loading */ + 'schedule-button-tooltip.validation.loading': 'Validating documents...', + /** Schedule release button tooltip when there are validation errors */ + 'schedule-button-tooltip.validation.error': 'Some documents have validation errors', + + /** Schedule release button tooltip when the release is already scheduled */ + 'schedule-button-tooltip.already-scheduled': 'This release is already scheduled', + + /** Title for unschedule release dialog */ + 'schedule-dialog.confirm-title': + 'Are you sure you want to schedule the release and all document versions for publishing?', + /** Description shown in unschedule relaease dialog */ + 'schedule-dialog.confirm-description_one': + "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.', + + /** Description for the confirm button for scheduling a release */ + 'schedule-dialog.confirm-button': 'Yes, schedule for publishing', + + /** Label for date picker when scheduling a release */ + 'schedule-dialog.select-publish-date-label': 'Schedule for publishing on', + + /** Title for unschedule release dialog */ + 'unschedule-dialog.confirm-title': 'Are you sure you want to unschedule the release?', + /** Description shown in unschedule relaease dialog */ + '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 */ @@ -162,9 +199,17 @@ const releasesLocaleStrings = { /** Header for the document table in the release tool - time */ 'table-header.time': 'Time', /** Text for toast when release failed to publish */ - 'toast.error': "Failed to publish the '{{title}}'", + 'toast.publish.error': "Failed to publish '{{title}}': {{error}}", /** Text for toast when release has been published */ - 'toast.published': "The '{{title}}' release was published.", + 'toast.publish.success': "The '{{title}}' release was published.", + /** Text for toast when release failed to schedule */ + 'toast.schedule.error': "Failed to schedule '{{title}}': {{error}}", + /** Text for toast when release has been scheduled */ + 'toast.schedule.success': "The '{{title}}' release was scheduled.", + /** Text for toast when release failed to unschedule */ + 'toast.unschedule.error': "Failed to unscheduled '{{title}}': {{error}}", + /** Text for toast when release has been unschedule */ + 'toast.unschedule.success': "The '{{title}}' release was unscheduled.", } /** diff --git a/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx index 1042ac53d08..b66c9df3301 100644 --- a/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx +++ b/packages/sanity/src/core/releases/navbar/__tests__/ReleasesNav.test.tsx @@ -67,7 +67,7 @@ describe('ReleasesNav', () => { it('should have clear button to unset perspective when a perspective is chosen', async () => { mockUsePerspective.mockReturnValue({ currentGlobalBundle: { - _id: 'system-tmp-releases.a-release', + _id: '_.releases.a-release', metadata: {title: 'Test Release'}, }, setPerspective: mockSetPerspective, @@ -83,7 +83,7 @@ describe('ReleasesNav', () => { it('should list the title of the chosen perspective', async () => { mockUsePerspective.mockReturnValue({ currentGlobalBundle: { - _id: 'system-tmp-releases.a-release', + _id: '_.releases.a-release', metadata: { title: 'Test Bundle', }, @@ -99,7 +99,7 @@ describe('ReleasesNav', () => { it('should show release avatar for chosen perspective', async () => { mockUsePerspective.mockReturnValue({ currentGlobalBundle: { - _id: 'system-tmp-releases.a-release', + _id: '_.releases.a-release', metadata: {title: 'Test Bundle', releaseType: 'asap'}, }, setPerspective: mockSetPerspective, diff --git a/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleasePublishAllButton.tsx b/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleasePublishAllButton.tsx index 3f51c6c963b..e8c61241ab5 100644 --- a/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleasePublishAllButton.tsx +++ b/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleasePublishAllButton.tsx @@ -9,18 +9,18 @@ import {type ReleaseDocument} from '../../../../store' import {useReleaseOperations} from '../../../../store/release/useReleaseOperations' import {PublishedRelease} from '../../../__telemetry__/releases.telemetry' import {releasesLocaleNamespace} from '../../../i18n' -import {type DocumentInBundleResult} from '../../../tool/detail/useBundleDocuments' +import {type DocumentInRelease} from '../../../tool/detail/useBundleDocuments' import {useObserveDocumentRevisions} from './useObserveDocumentRevisions' interface ReleasePublishAllButtonProps { release: ReleaseDocument - releaseDocuments: DocumentInBundleResult[] + documents: DocumentInRelease[] disabled?: boolean } export const ReleasePublishAllButton = ({ release, - releaseDocuments, + documents, disabled, }: ReleasePublishAllButtonProps) => { const toast = useToast() @@ -32,11 +32,11 @@ export const ReleasePublishAllButton = ({ ) const publishedDocumentsRevisions = useObserveDocumentRevisions( - releaseDocuments.map(({document}) => document), + documents.map(({document}) => document), ) - const isValidatingDocuments = releaseDocuments.some(({validation}) => validation.isValidating) - const hasDocumentValidationErrors = releaseDocuments.some(({validation}) => validation.hasError) + const isValidatingDocuments = documents.some(({validation}) => validation.isValidating) + const hasDocumentValidationErrors = documents.some(({validation}) => validation.hasError) const isPublishButtonDisabled = disabled || isValidatingDocuments || hasDocumentValidationErrors @@ -47,7 +47,7 @@ export const ReleasePublishAllButton = ({ setPublishBundleStatus('publishing') await publishRelease( release._id, - releaseDocuments.map(({document}) => document), + documents.map(({document}) => document), publishedDocumentsRevisions, ) telemetry.log(PublishedRelease) @@ -73,7 +73,7 @@ export const ReleasePublishAllButton = ({ } finally { setPublishBundleStatus('idle') } - }, [release, releaseDocuments, publishRelease, publishedDocumentsRevisions, t, telemetry, toast]) + }, [release, documents, publishRelease, publishedDocumentsRevisions, t, telemetry, toast]) const confirmPublishDialog = useMemo(() => { if (publishBundleStatus === 'idle') return null @@ -100,21 +100,15 @@ export const ReleasePublishAllButton = ({ i18nKey="publish-dialog.confirm-publish-description" values={{ title: release.metadata.title, - releaseDocumentsLength: releaseDocuments.length, - count: releaseDocuments.length, + releaseDocumentsLength: documents.length, + count: documents.length, }} /> } ) - }, [ - release.metadata.title, - releaseDocuments.length, - handleConfirmPublishAll, - publishBundleStatus, - t, - ]) + }, [release.metadata.title, documents.length, handleConfirmPublishAll, publishBundleStatus, t]) const publishTooltipContent = useMemo(() => { if (!hasDocumentValidationErrors && !isValidatingDocuments) return null diff --git a/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleaseScheduleButton.tsx b/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleaseScheduleButton.tsx new file mode 100644 index 00000000000..d54ef410784 --- /dev/null +++ b/packages/sanity/src/core/releases/tool/components/ReleasePublishAllButton/ReleaseScheduleButton.tsx @@ -0,0 +1,197 @@ +import {CalendarIcon, ErrorOutlineIcon} from '@sanity/icons' +import {useTelemetry} from '@sanity/telemetry/react' +import {Flex, Stack, Text, useToast} from '@sanity/ui' +import {useCallback, useMemo, useState} from 'react' + +import {Button, Dialog} from '../../../../../ui-components' +import {MONTH_PICKER_VARIANT} from '../../../../../ui-components/inputs/DateInputs/calendar/Calendar' +import {type CalendarLabels} from '../../../../../ui-components/inputs/DateInputs/calendar/types' +import {DateTimeInput} from '../../../../../ui-components/inputs/DateInputs/DateTimeInput' +import {getCalendarLabels} from '../../../../form/inputs/DateInputs/utils' +import {useDateTimeFormat} from '../../../../hooks' +import {Translate, useTranslation} from '../../../../i18n' +import {type ReleaseDocument} from '../../../../store' +import {useReleaseOperations} from '../../../../store/release/useReleaseOperations' +import {ScheduledRelease} from '../../../__telemetry__/releases.telemetry' +import {releasesLocaleNamespace} from '../../../i18n' +import {type DocumentInRelease} from '../../detail/useBundleDocuments' + +interface ReleaseScheduleButtonProps { + release: ReleaseDocument + documents: DocumentInRelease[] + disabled?: boolean +} + +export const ReleaseScheduleButton = ({ + release, + disabled, + documents, +}: ReleaseScheduleButtonProps) => { + const toast = useToast() + const {schedule} = useReleaseOperations() + const {t} = useTranslation(releasesLocaleNamespace) + const telemetry = useTelemetry() + const [status, setStatus] = useState<'idle' | 'confirm' | 'scheduling'>('idle') + + const isValidatingDocuments = documents.some(({validation}) => validation.isValidating) + const hasDocumentValidationErrors = documents.some(({validation}) => validation.hasError) + const isScheduleButtonDisabled = disabled || isValidatingDocuments || hasDocumentValidationErrors + + const handleConfirmSchedule = useCallback(async () => { + try { + setStatus('scheduling') + await schedule(release._id, new Date(release.metadata.intendedPublishAt!)) + telemetry.log(ScheduledRelease) + toast.push({ + closable: true, + status: 'success', + title: ( + + + + ), + }) + } catch (schedulingError) { + toast.push({ + status: 'error', + title: ( + + + + ), + }) + console.error(schedulingError) + } finally { + setStatus('idle') + } + }, [ + schedule, + release._id, + release.metadata.intendedPublishAt, + release.metadata.title, + telemetry, + toast, + t, + ]) + const {t: coreT} = useTranslation() + const calendarLabels: CalendarLabels = useMemo(() => getCalendarLabels(coreT), [coreT]) + const dateFormatter = useDateTimeFormat() + + const [publishAt, setPublishAt] = useState( + release.metadata.intendedPublishAt ? new Date(release.metadata.intendedPublishAt) : new Date(), + ) + const confirmScheduleDialog = useMemo(() => { + if (status === 'idle') return null + + return ( + setStatus('idle')} + footer={{ + confirmButton: { + text: t('schedule-dialog.confirm-button'), + tone: 'default', + onClick: handleConfirmSchedule, + loading: status === 'scheduling', + disabled: status === 'scheduling', + }, + }} + > + + + + + + + + ) + }, [ + status, + t, + documents.length, + handleConfirmSchedule, + calendarLabels, + release.metadata.title, + publishAt, + dateFormatter, + ]) + + const scheduleTooltipContent = useMemo(() => { + const tooltipText = () => { + if (isValidatingDocuments) { + return t('schedule-button-tooltip.validation.loading') + } + + if (hasDocumentValidationErrors) { + return t('schedule-button-tooltip.validation.error') + } + + if (release.state === 'scheduled' || release.state === 'scheduling') { + return t('schedule-button-tooltip.already-scheduled') + } + return null + } + + return ( + + + + {tooltipText()} + + + ) + }, [hasDocumentValidationErrors, isValidatingDocuments, release.state, t]) + + return ( + <> +