Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(releases): adding correct confirmation dialog to archive release flow #7827

Merged
merged 6 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice nice nice nice

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {type ReleaseDocument} from '../store/types'

export const activeScheduledRelease: ReleaseDocument = {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking that we'd continue to create others here eg activeASAPRelease as are necessary for test cases across our suite. All fixtures would be correct and valid states, eg activeASAPRelease would not have an intendedPublishAt since that is not a valid state machine value

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😚 👌

_id: '_.releases.activeRelease',
_type: 'system.release',
createdBy: '',
_createdAt: '2023-10-01T08:00:00Z',
_updatedAt: '2023-10-01T09:00:00Z',
state: 'active',
name: 'activeRelease',
metadata: {
title: 'active Release',
releaseType: 'scheduled',
intendedPublishAt: '2023-10-01T10:00:00Z',
description: 'active Release description',
},
}
14 changes: 14 additions & 0 deletions packages/sanity/src/core/releases/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ const releasesLocaleStrings = {
'actions.summary': 'Summary',
/** Label for unarchiving a release */
'action.unarchive': 'Unarchive release',
/** Title for the dialog confirming the archive of a release */
'archive-dialog.confirm-archive-title':
"Are you sure you want to archive the <strong>'{{title}}'</strong> release?",
/** Description for the dialog confirming the archive of a release with one document */
'archive-dialog.confirm-archive-description_one': 'This will archive 1 document version.',
/** Description for the dialog confirming the publish of a release with more than one document */
'archive-dialog.confirm-archive-description_other':
'This will archive {{count}} document versions.',
/** Label for the button to proceed with archiving a release */
'archive-dialog.confirm-archive-button': 'Yes, archive now',

/** Title for changes to published documents */
'changes-published-docs.title': 'Changes to published documents',
Expand Down Expand Up @@ -203,6 +213,10 @@ const releasesLocaleStrings = {
'table-header.edited': 'Edited',
/** Header for the document table in the release tool - time */
'table-header.time': 'Time',
/** Text for toast when release has been archived */
'toast.archive.success': "The '<strong>{{title}}</strong>' release was archived.",
/** Text for toast when release failed to archive */
'toast.archive.error': "Failed to archive '<strong>{{title}}</strong>': {{error}}",
/** Text for toast when release failed to publish */
'toast.publish.error': "Failed to publish '<strong>{{title}}</strong>': {{error}}",
/** Text for toast when release has been published */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {type Mocked, vi} from 'vitest'

import {type ReleaseOperationsStore} from '../../createReleaseOperationStore'

export const useReleaseOperationsMock: Mocked<ReleaseOperationsStore> = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a great pattern to me!

archive: vi.fn(),
unarchive: vi.fn(),
createRelease: vi.fn(),
createVersion: vi.fn(),
discardVersion: vi.fn(),
publishRelease: vi.fn(),
schedule: vi.fn(),
unschedule: vi.fn(),
updateRelease: vi.fn(),
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,12 @@ export function createReleaseOperationsStore(options: {
client: SanityClient
}): ReleaseOperationsStore {
const {client} = options
const handleCreateRelease = async (release: EditableReleaseDocument) => {
await requestAction(client, {
const handleCreateRelease = (release: EditableReleaseDocument) =>
requestAction(client, {
actionType: 'sanity.action.release.create',
releaseId: getBundleIdFromReleaseDocumentId(release._id),
[METADATA_PROPERTY_NAME]: release.metadata,
})
}

const handleUpdateRelease = async (release: EditableReleaseDocument) => {
const bundleId = getBundleIdFromReleaseDocumentId(release._id)
Expand All @@ -56,49 +55,46 @@ export function createReleaseOperationsStore(options: {
})
}

const handlePublishRelease = async (releaseId: string) =>
const handlePublishRelease = (releaseId: string) =>
requestAction(client, [
{
actionType: 'sanity.action.release.publish',
releaseId: getBundleIdFromReleaseDocumentId(releaseId),
},
])

const handleScheduleRelease = async (releaseId: string, publishAt: Date) => {
await requestAction(client, [
const handleScheduleRelease = (releaseId: string, publishAt: Date) =>
requestAction(client, [
{
actionType: 'sanity.action.release.schedule',
releaseId: getBundleIdFromReleaseDocumentId(releaseId),
publishAt: publishAt.toISOString(),
},
])
}
const handleUnscheduleRelease = async (releaseId: string) => {
await requestAction(client, [

const handleUnscheduleRelease = (releaseId: string) =>
requestAction(client, [
{
actionType: 'sanity.action.release.unschedule',
releaseId: getBundleIdFromReleaseDocumentId(releaseId),
},
])
}

const handleArchiveRelease = async (releaseId: string) => {
await requestAction(client, [
const handleArchiveRelease = (releaseId: string) =>
requestAction(client, [
{
actionType: 'sanity.action.release.archive',
releaseId: getBundleIdFromReleaseDocumentId(releaseId),
},
])
}

const handleUnarchiveRelease = async (releaseId: string) => {
await requestAction(client, [
const handleUnarchiveRelease = (releaseId: string) =>
requestAction(client, [
{
actionType: 'sanity.action.release.unarchive',
releaseId: getBundleIdFromReleaseDocumentId(releaseId),
},
])
}

const handleCreateVersion = async (releaseId: string, documentId: string) => {
// the documentId will show you where the document is coming from and which
Expand Down Expand Up @@ -127,18 +123,13 @@ export function createReleaseOperationsStore(options: {
: client.create(versionDocument))
}

const handleDiscardVersion = async (releaseId: string, documentId: string) => {
if (!document) {
throw new Error(`Document with id ${documentId} not found`)
}

await requestAction(client, [
const handleDiscardVersion = (releaseId: string, documentId: string) =>
requestAction(client, [
{
actionType: 'sanity.action.document.discard',
draftId: getVersionId(documentId, releaseId),
},
])
}

return {
archive: handleArchiveRelease,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import {ArchiveIcon, ArrowRightIcon, EllipsisHorizontalIcon, UnarchiveIcon} from '@sanity/icons'
import {ArchiveIcon, EllipsisHorizontalIcon, UnarchiveIcon} from '@sanity/icons'
import {useTelemetry} from '@sanity/telemetry/react'
import {Box, Flex, Menu, Spinner, Text} from '@sanity/ui'
import {type FormEventHandler, useState} from 'react'
import {Menu, Spinner, Text, useToast} from '@sanity/ui'
import {useCallback, useMemo, useState} from 'react'

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

export type ReleaseMenuButtonProps = {
disabled?: boolean
Expand All @@ -19,35 +20,125 @@ export type ReleaseMenuButtonProps = {
const ARCHIVABLE_STATES = ['active', 'published']

export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) => {
const {archive, unarchive} = useReleaseOperations()
const toast = useToast()
const {archive} = useReleaseOperations()
const {loading: isLoadingReleaseDocuments, results: releaseDocuments} = useBundleDocuments(
getBundleIdFromReleaseDocumentId(release._id),
)
const [isPerformingOperation, setIsPerformingOperation] = useState(false)
const [selectedAction, setSelectedAction] = useState<'edit' | 'confirm-archive'>()

const releaseMenuDisabled = !release || disabled
const releaseMenuDisabled = !release || isLoadingReleaseDocuments || disabled
const {t} = useTranslation(releasesLocaleNamespace)
const telemetry = useTelemetry()

const handleArchive = async (e: Parameters<FormEventHandler<HTMLFormElement>>[0]) => {
const handleArchive = useCallback(async () => {
if (releaseMenuDisabled) return
e.preventDefault()

setIsPerformingOperation(true)
await archive(release._id)
try {
setIsPerformingOperation(true)
await archive(release._id)

// it's in the process of becoming true, so the event we want to track is archive
telemetry.log(ArchivedRelease)
setIsPerformingOperation(false)
}
// it's in the process of becoming true, so the event we want to track is archive
telemetry.log(ArchivedRelease)
toast.push({
closable: true,
status: 'success',
title: (
<Text muted size={1}>
<Translate
t={t}
i18nKey="toast.archive.success"
values={{title: release.metadata.title}}
/>
</Text>
),
})
} catch (archivingError) {
toast.push({
status: 'error',
title: (
<Text muted size={1}>
<Translate
t={t}
i18nKey="toast.archive.error"
values={{title: release.metadata.title, error: archivingError.toString()}}
/>
</Text>
),
})
console.error(archivingError)
} finally {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rogue console log or meant to be here? :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I followed similar approach to what we do in Publish and schedule, so this is intended

setIsPerformingOperation(false)
setSelectedAction(undefined)
}
}, [archive, release._id, release.metadata.title, releaseMenuDisabled, t, telemetry, toast])

const handleUnarchive = async () => {
setIsPerformingOperation(true)
await unarchive(release._id)

// it's in the process of becoming false, so the event we want to track is unarchive
telemetry.log(UnarchivedRelease)
setIsPerformingOperation(false)
// noop
// TODO: similar to handleArchive - complete once server action exists
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the reason for deleting this? Do we expect unarchive to change significantly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will change from what it currently is in corel (will be updated to to be similar to archive). I considered implementing it, but feel it's better to complete the full task once the action can be test alongside. There's a separate task I'm monitoring and will complete once ready


const confirmArchiveDialog = useMemo(() => {
if (selectedAction !== 'confirm-archive') return null

const dialogDescription =
releaseDocuments.length === 1
? 'archive-dialog.confirm-archive-description_one'
jordanl17 marked this conversation as resolved.
Show resolved Hide resolved
: 'archive-dialog.confirm-archive-description_other'

return (
<Dialog
id="confirm-archive-dialog"
data-testid="confirm-archive-dialog"
header={
<Translate
t={t}
i18nKey={'archive-dialog.confirm-archive-title'}
values={{
title: release.metadata.title,
}}
/>
}
onClose={() => setSelectedAction(undefined)}
footer={{
confirmButton: {
text: t('archive-dialog.confirm-archive-button'),
tone: 'positive',
onClick: handleArchive,
loading: isPerformingOperation,
disabled: isPerformingOperation,
},
}}
>
<Text muted size={1}>
<Translate
t={t}
i18nKey={dialogDescription}
values={{
count: releaseDocuments.length,
}}
/>
</Text>
</Dialog>
)
}, [
handleArchive,
isPerformingOperation,
release.metadata.title,
releaseDocuments.length,
selectedAction,
t,
])

const handleOnInitiateArchive = useCallback(() => {
if (releaseDocuments.length > 0) {
setSelectedAction('confirm-archive')
} else {
handleArchive()
}
}, [handleArchive, releaseDocuments.length])

return (
<>
<MenuButton
Expand All @@ -67,17 +158,19 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
{!release?.state || release.state === 'archived' ? (
<MenuItem
onClick={handleUnarchive}
// TODO: disabled as CL action not yet impl
disabled
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a linear story for this? 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we do corel-256

icon={UnarchiveIcon}
text={t('action.unarchive')}
data-testid="unarchive-release"
/>
) : (
<MenuItem
tooltipProps={{
disabled: ARCHIVABLE_STATES.includes(release.state),
disabled: ARCHIVABLE_STATES.includes(release.state) || isPerformingOperation,
content: t('action.archive.tooltip'),
}}
onClick={() => setSelectedAction('confirm-archive')}
onClick={handleOnInitiateArchive}
icon={ArchiveIcon}
text={t('action.archive')}
data-testid="archive-release"
Expand All @@ -94,36 +187,7 @@ export const ReleaseMenuButton = ({disabled, release}: ReleaseMenuButtonProps) =
tone: 'default',
}}
/>
{selectedAction === 'confirm-archive' && (
<Dialog
header="Confirm archiving release"
id="create-release-dialog"
onClose={() => setSelectedAction(undefined)}
width={1}
>
<form onSubmit={handleArchive}>
<Box padding={4}>
{/* TODO localize string */}
{/* eslint-disable-next-line i18next/no-literal-string */}
<Text>Are you sure you want to archive the release? There's no going back (yet)</Text>
{isPerformingOperation && <LoadingBlock showText title={'archiving, wait'} />}
</Box>
<Flex justify="flex-end" paddingTop={5}>
<Button
size="large"
iconRight={ArrowRightIcon}
type="submit"
text={
// TODO localize string
// eslint-disable-next-line @sanity/i18n/no-attribute-string-literals
'Archive release'
}
data-testid="archive-release-button"
/>
</Flex>
</form>
</Dialog>
)}
{confirmArchiveDialog}
</>
)
}
Loading
Loading