Skip to content

Commit

Permalink
feat(releases): unschedule release from release menu button menu (#7954)
Browse files Browse the repository at this point in the history
  • Loading branch information
jordanl17 authored Dec 6, 2024
1 parent e9a1674 commit 5677b53
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 55 deletions.
4 changes: 2 additions & 2 deletions packages/sanity/src/core/releases/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const releasesLocaleStrings = {
'action.open': 'Open',
/** Action text for scheduling a release */
'action.schedule': 'Schedule for publishing...',
/** Action text for scheduling a release */
'action.unschedule': 'Unschedule',
/** Action text for unscheduling a release */
'action.unschedule': 'Unschedule for publishing',
/** Action text for publishing all documents in a release (and the release itself) */
'action.publish-all-documents': 'Publish all documents',
/** Text for the review changes button in release tool */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,72 +1,106 @@
import {ArchiveIcon, EllipsisHorizontalIcon, TrashIcon, UnarchiveIcon} from '@sanity/icons'
import {
ArchiveIcon,
CloseCircleIcon,
EllipsisHorizontalIcon,
TrashIcon,
UnarchiveIcon,
} from '@sanity/icons'
import {type DefinedTelemetryLog, useTelemetry} from '@sanity/telemetry/react'
import {Menu, Spinner, Text, useToast} from '@sanity/ui'
import {useCallback, useMemo, useState} from 'react'
import {useRouter} from 'sanity/router'

import {Button, Dialog, MenuButton, MenuItem} from '../../../../../ui-components'
import {Translate, useTranslation} from '../../../../i18n'
import {ArchivedRelease, DeletedRelease} from '../../../__telemetry__/releases.telemetry'
import {
ArchivedRelease,
DeletedRelease,
UnscheduledRelease,
} from '../../../__telemetry__/releases.telemetry'
import {releasesLocaleNamespace} from '../../../i18n'
import {type ReleaseDocument} from '../../../store/types'
import {useReleaseOperations} from '../../../store/useReleaseOperations'
import {getReleaseIdFromReleaseDocumentId} from '../../../util/getReleaseIdFromReleaseDocumentId'
import {useBundleDocuments} from '../../detail/useBundleDocuments'

export type ReleaseMenuButtonProps = {
disabled?: boolean
/** defaults to false
* set true if release primary CTA options should not
* be shown in the menu eg. unschedule, publish
*/
ignoreCTA?: boolean
release: ReleaseDocument
}

type ReleaseAction = 'archive' | 'delete'
type ReleaseAction = 'archive' | 'delete' | 'unschedule'

interface ReleaseActionMap {
dialogId: string
dialogHeaderI18nKey: string
dialogDescriptionSingularI18nKey: string
dialogDescriptionMultipleI18nKey: string
dialogConfirmButtonI18nKey: string
interface BaseReleaseActionsMap {
toastSuccessI18nKey: string
toastFailureI18nKey: string
telemetry: DefinedTelemetryLog<void>
}

const RELEASE_ACTION_MAP: Record<ReleaseAction, ReleaseActionMap> = {
interface DialogActionsMap extends BaseReleaseActionsMap {
confirmDialog: {
dialogId: string
dialogHeaderI18nKey: string
dialogDescriptionSingularI18nKey: string
dialogDescriptionMultipleI18nKey: string
dialogConfirmButtonI18nKey: string
}
}

const RELEASE_ACTION_MAP: Record<
ReleaseAction,
DialogActionsMap | (BaseReleaseActionsMap & {confirmDialog: false})
> = {
delete: {
dialogId: 'confirm-delete-dialog',
dialogHeaderI18nKey: 'delete-dialog.confirm-delete.header',
dialogDescriptionSingularI18nKey: 'delete-dialog.confirm-delete-description_one',
dialogDescriptionMultipleI18nKey: 'delete-dialog.confirm-delete-description_other',
dialogConfirmButtonI18nKey: 'delete-dialog.confirm-delete-button',
confirmDialog: {
dialogId: 'confirm-delete-dialog',
dialogHeaderI18nKey: 'delete-dialog.confirm-delete.header',
dialogDescriptionSingularI18nKey: 'delete-dialog.confirm-delete-description_one',
dialogDescriptionMultipleI18nKey: 'delete-dialog.confirm-delete-description_other',
dialogConfirmButtonI18nKey: 'delete-dialog.confirm-delete-button',
},
toastSuccessI18nKey: 'toast.delete.success',
toastFailureI18nKey: 'toast.delete.error',
telemetry: DeletedRelease,
},
archive: {
dialogId: 'confirm-archive-dialog',
dialogHeaderI18nKey: 'archive-dialog.confirm-archive-header',
dialogDescriptionSingularI18nKey: 'archive-dialog.confirm-archive-description_one',
dialogDescriptionMultipleI18nKey: 'archive-dialog.confirm-archive-description_other',
dialogConfirmButtonI18nKey: 'archive-dialog.confirm-archive-button',
confirmDialog: {
dialogId: 'confirm-archive-dialog',
dialogHeaderI18nKey: 'archive-dialog.confirm-archive-header',
dialogDescriptionSingularI18nKey: 'archive-dialog.confirm-archive-description_one',
dialogDescriptionMultipleI18nKey: 'archive-dialog.confirm-archive-description_other',
dialogConfirmButtonI18nKey: 'archive-dialog.confirm-archive-button',
},
toastSuccessI18nKey: 'toast.archive.success',
toastFailureI18nKey: 'toast.archive.error',
telemetry: ArchivedRelease,
},
unschedule: {
confirmDialog: false,
toastSuccessI18nKey: 'toast.unschedule.success',
toastFailureI18nKey: 'toast.unschedule.error',
telemetry: UnscheduledRelease,
},
}

export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) => {
export const ReleaseMenuButton = ({ignoreCTA, release}: ReleaseMenuButtonProps) => {
const toast = useToast()
const router = useRouter()
const {archive, deleteRelease} = useReleaseOperations()
const {archive, deleteRelease, unschedule} = useReleaseOperations()
const {loading: isLoadingReleaseDocuments, results: releaseDocuments} = useBundleDocuments(
getReleaseIdFromReleaseDocumentId(release._id),
)
const [isPerformingOperation, setIsPerformingOperation] = useState(false)
const [selectedAction, setSelectedAction] = useState<ReleaseAction>()

const releaseMenuDisabled = !release || isLoadingReleaseDocuments || disabled
const releaseMenuDisabled = !release || isLoadingReleaseDocuments
const {t} = useTranslation(releasesLocaleNamespace)
const {t: tCore} = useTranslation()
const telemetry = useTelemetry()
const releaseTitle = release.metadata.title || tCore('release.placeholder-untitled-release')

const handleDelete = useCallback(async () => {
await deleteRelease(release._id)
Expand All @@ -81,7 +115,8 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =

const actionLookup = {
delete: handleDelete,
archive: archive,
archive,
unschedule,
}
const actionValues = RELEASE_ACTION_MAP[action]

Expand All @@ -97,7 +132,7 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
<Translate
t={t}
i18nKey={actionValues.toastSuccessI18nKey}
values={{title: release.metadata.title}}
values={{title: releaseTitle}}
/>
</Text>
),
Expand All @@ -110,7 +145,7 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
<Translate
t={t}
i18nKey={actionValues.toastFailureI18nKey}
values={{title: release.metadata.title, error: actionError.toString()}}
values={{title: releaseTitle, error: actionError.toString()}}
/>
</Text>
),
Expand All @@ -122,14 +157,15 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
}
},
[
archive,
releaseMenuDisabled,
handleDelete,
archive,
unschedule,
release._id,
release.metadata.title,
releaseMenuDisabled,
t,
telemetry,
toast,
t,
releaseTitle,
],
)

Expand All @@ -141,22 +177,24 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
const confirmActionDialog = useMemo(() => {
if (!selectedAction) return null

const actionValues = RELEASE_ACTION_MAP[selectedAction]
const {confirmDialog, ...actionValues} = RELEASE_ACTION_MAP[selectedAction]

if (!confirmDialog) return null

const dialogDescription =
releaseDocuments.length === 1
? actionValues.dialogDescriptionSingularI18nKey
: actionValues.dialogDescriptionMultipleI18nKey
? confirmDialog.dialogDescriptionSingularI18nKey
: confirmDialog.dialogDescriptionMultipleI18nKey

return (
<Dialog
id={actionValues.dialogId}
data-testid={actionValues.dialogId}
header={t(actionValues.dialogHeaderI18nKey, {title: release.metadata.title})}
id={confirmDialog.dialogId}
data-testid={confirmDialog.dialogId}
header={t(confirmDialog.dialogHeaderI18nKey, {title: releaseTitle})}
onClose={() => setSelectedAction(undefined)}
footer={{
confirmButton: {
text: t(actionValues.dialogConfirmButtonI18nKey),
text: t(confirmDialog.dialogConfirmButtonI18nKey),
tone: 'positive',
onClick: () => handleAction(selectedAction),
loading: isPerformingOperation,
Expand All @@ -180,15 +218,15 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
}, [
handleAction,
isPerformingOperation,
release.metadata.title,
releaseTitle,
releaseDocuments.length,
selectedAction,
t,
])

const handleOnInitiateAction = useCallback(
(action: ReleaseAction) => {
if (releaseDocuments.length > 0) {
if (releaseDocuments.length > 0 && RELEASE_ACTION_MAP[action].confirmDialog) {
setSelectedAction(action)
} else {
handleAction(action)
Expand All @@ -215,7 +253,7 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
return (
<MenuItem
tooltipProps={{
disabled: ['scheduled', 'scheduling'].includes(release.state) || isPerformingOperation,
disabled: !['scheduled', 'scheduling'].includes(release.state) || isPerformingOperation,
content: t('action.archive.tooltip'),
}}
onClick={() => handleOnInitiateAction('archive')}
Expand All @@ -241,6 +279,27 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
)
}, [handleOnInitiateAction, isPerformingOperation, release.state, releaseMenuDisabled, t])

const unscheduleMenuItem = useMemo(() => {
if (ignoreCTA || (release.state !== 'scheduled' && release.state !== 'scheduling')) return null

return (
<MenuItem
onClick={() => handleOnInitiateAction('unschedule')}
disabled={releaseMenuDisabled || isPerformingOperation}
icon={CloseCircleIcon}
text={t('action.unschedule')}
data-testid="unschedule-release-menu-item"
/>
)
}, [
handleOnInitiateAction,
ignoreCTA,
isPerformingOperation,
release.state,
releaseMenuDisabled,
t,
])

return (
<>
<MenuButton
Expand All @@ -257,6 +316,7 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
id="release-menu"
menu={
<Menu>
{unscheduleMenuItem}
{archiveUnarchiveMenuItem}
{deleteMenuItem}
</Menu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {
activeScheduledRelease,
archivedScheduledRelease,
publishedASAPRelease,
scheduledRelease,
} from '../../../../__fixtures__/release.fixture'
import {releasesUsEnglishLocaleBundle} from '../../../../i18n'
import {type ReleaseDocument} from '../../../../index'
import {type ReleaseDocument, type ReleaseState} from '../../../../index'
import {
mockUseReleaseOperations,
useReleaseOperationsMockReturn,
Expand All @@ -35,11 +36,11 @@ vi.mock('sanity/router', async (importOriginal) => ({
useRouter: vi.fn().mockReturnValue({state: {}, navigate: vi.fn()}),
}))

const renderTest = async ({release, disabled = false}: ReleaseMenuButtonProps) => {
const renderTest = async ({release, ignoreCTA = false}: ReleaseMenuButtonProps) => {
const wrapper = await createTestProvider({
resources: [releasesUsEnglishLocaleBundle],
})
return render(<ReleaseMenuButton disabled={disabled} release={release} />, {wrapper})
return render(<ReleaseMenuButton ignoreCTA={ignoreCTA} release={release} />, {wrapper})
}

describe('ReleaseMenuButton', () => {
Expand Down Expand Up @@ -258,6 +259,41 @@ describe('ReleaseMenuButton', () => {
})
})

describe('unschedule release', () => {
test.each([
{state: 'archived', fixture: archivedScheduledRelease},
{state: 'active', fixture: activeScheduledRelease},
{state: 'published', fixture: publishedASAPRelease},
])('will not allow for unscheduling of $state releases', async ({fixture}) => {
await renderTest({release: fixture})

await waitFor(() => {
screen.getByTestId('release-menu-button')
})
fireEvent.click(screen.getByTestId('release-menu-button'))

expect(screen.queryByTestId('unschedule-release-menu-item')).not.toBeInTheDocument()
})

test.each([
{state: 'scheduled', fixture: scheduledRelease},
{state: 'scheduling', fixture: {...scheduledRelease, state: 'scheduling' as ReleaseState}},
])('will unschedule a $state release', async ({fixture}) => {
await renderTest({release: fixture})

await waitFor(() => {
screen.getByTestId('release-menu-button')
})

fireEvent.click(screen.getByTestId('release-menu-button'))

fireEvent.click(screen.getByTestId('unschedule-release-menu-item'))

// does not require confirmation
expect(useReleaseOperations().unschedule).toHaveBeenCalledWith(fixture._id)
})
})

test.todo('will unarchive an archived release', async () => {
/** @todo update once unarchive has been implemented */
const archivedRelease: ReleaseDocument = {...activeScheduledRelease, state: 'archived'}
Expand All @@ -276,15 +312,15 @@ describe('ReleaseMenuButton', () => {
})
})

test('will be disabled', async () => {
await renderTest({release: activeScheduledRelease, disabled: true})

const actionsButton = screen.getByTestId('release-menu-button')
test('will hide CTAs when ignoreCTA is true', async () => {
await renderTest({release: scheduledRelease, ignoreCTA: true})

expect(actionsButton).toBeDisabled()
await waitFor(() => {
screen.getByTestId('release-menu-button')
})

fireEvent.click(actionsButton)
fireEvent.click(screen.getByTestId('release-menu-button'))

expect(screen.queryByTestId('archive-release-menu-item')).not.toBeInTheDocument()
expect(screen.queryByTestId('unschedule-release-menu-item')).not.toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function ReleaseDashboardFooter(props: {
const {documents, release} = props

const releaseActionButton = useMemo(() => {
if (release.state === 'scheduled' || release.state === 'scheduling') {
if (release.metadata.releaseType === 'scheduled') {
return isReleaseScheduledOrScheduling(release) ? (
<ReleaseUnscheduleButton
release={release}
Expand Down Expand Up @@ -57,7 +57,7 @@ export function ReleaseDashboardFooter(props: {

<Flex flex="none" gap={1}>
{releaseActionButton}
<ReleaseMenuButton release={release} />
<ReleaseMenuButton release={release} ignoreCTA />
</Flex>
</Flex>
</Card>
Expand Down

0 comments on commit 5677b53

Please sign in to comment.