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(sanity): display release errors #8482

Merged
merged 7 commits into from
Feb 7, 2025
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
1 change: 1 addition & 0 deletions packages/sanity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@
"@types/semver": "^6.2.3",
"@types/tar-fs": "^2.0.1",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/expect": "^3.0.5",
"@vvo/tzdb": "6.137.0",
"babel-plugin-react-compiler": "19.0.0-beta-714736e-20250131",
"blob-polyfill": "^9.0.20240710",
Expand Down
41 changes: 37 additions & 4 deletions packages/sanity/src/core/perspective/ReleasesToolLink.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
import {CalendarIcon} from '@sanity/icons'
// eslint-disable-next-line no-restricted-imports -- Bundle Button requires more fine-grained styling than studio button
import {Box, Button} from '@sanity/ui'
import {Button} from '@sanity/ui'
import {useCallback} from 'react'
import {useTranslation} from 'react-i18next'
import {useObservable} from 'react-rx'
import {useRouterState} from 'sanity/router'
import {styled} from 'styled-components'

import {Tooltip} from '../../ui-components/tooltip/Tooltip'
import {RELEASES_TOOL_NAME} from '../releases/plugin'
import {useReleasesStore} from '../releases/store/useReleasesStore'
import {ToolLink} from '../studio/components/navbar/tools/ToolLink'

const Dot = styled.div({
width: 4,
height: 4,
borderRadius: 3,
boxShadow: '0 0 0 1px var(--card-bg-color)',
})

const Container = styled.div`
flex: none;

// The children in button is rendered inside a span, we need to absolutely position it.
span:has(> [data-ui='status-icon']) {
position: absolute;
top: 6px;
right: 6px;
padding: 0;
}
`
/**
* represents the calendar icon for the releases tool.
* It will be hidden if users have turned off releases.
*/
export function ReleasesToolLink(): React.JSX.Element {
const {t} = useTranslation()
const {errorCount$} = useReleasesStore()
const errorCount = useObservable(errorCount$)
const hasError = errorCount !== 0

const activeToolName = useRouterState(
useCallback(
Expand All @@ -24,7 +48,7 @@ export function ReleasesToolLink(): React.JSX.Element {
)

return (
<Box data-testid="releases-tool-link" flex="none">
<Container data-testid="releases-tool-link">
<Tooltip content={t('release.navbar.tooltip')}>
<Button
as={ToolLink}
Expand All @@ -36,8 +60,17 @@ export function ReleasesToolLink(): React.JSX.Element {
radius="full"
selected={activeToolName === RELEASES_TOOL_NAME}
space={2}
/>
>
{hasError && (
<Dot
data-ui="status-icon"
style={{
backgroundColor: `var(--card-badge-critical-dot-color)`,
}}
/>
)}
</Button>
</Tooltip>
</Box>
</Container>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,20 @@ export const activeUndecidedRelease: ReleaseDocument = {
description: 'undecided Release description',
},
}

export const activeUndecidedErrorRelease: ReleaseDocument = {
_rev: 'undecidedErrorRev',
_id: '_.releases.rUndecidedError',
_type: 'system.release',
_createdAt: '2023-10-10T08:00:00Z',
_updatedAt: '2023-10-10T09:00:00Z',
state: 'active',
metadata: {
title: 'undecided Error Release',
releaseType: 'undecided',
description: 'undecided Error Release description',
},
error: {
message: 'An unexpected error occurred during publication.',
Copy link
Member

Choose a reason for hiding this comment

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

Q: more a musing and not blocking for this work, but I wonder how we can get these sorts of error messages to work with i18n. Perhaps in the future it best we avoid just passing through the message and focus on error types so we can localise them

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree in principle, however error.message reflects a technical error from the server and is not intended to be presented to the user in the UI (we do output it, for debugging purposes). When we know about other types of error that will be encapsulated here, we'll need to use some heuristic to show a relevant (localised) message in the UI.

},
}
5 changes: 5 additions & 0 deletions packages/sanity/src/core/releases/i18n/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,13 @@ const releasesLocaleStrings = {
/** Label when a release has been deleted by a different user */
'deleted-release': "The '<strong>{{title}}</strong>' release has been deleted",

/** Title text displayed for technical error details */
'error-details-title': 'Error details',
/** Title text when error during release update */
'failed-edit-title': 'Failed to save changes',
/** Title text displayed for releases that failed to publish */
'failed-publish-title': 'Failed to publish',

/**The text that will be shown in the footer to indicate the time the release was archived */
'footer.status.archived': 'Archived',
/**The text that will be shown in the footer to indicate the time the release was created */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import {describe, expect, it} from 'vitest'

import {NO_EMISSION} from '../../../../../test/matchers/toMatchEmissions'
Copy link
Member

Choose a reason for hiding this comment

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

👀

import {
activeUndecidedErrorRelease,
activeUndecidedRelease,
archivedScheduledRelease,
publishedASAPRelease,
scheduledRelease,
} from '../../__fixtures__/release.fixture'
import {releaseStoreErrorCount} from '../createReleaseStore'

describe('releaseStoreErrorCount', () => {
it('emits the count of releases in an error state when the count changes', async () => {
await expect(releaseStoreErrorCount).toMatchEmissions([
[
{
releases: new Map([['a', activeUndecidedRelease]]),
state: 'loaded',
},
0,
],
[
{
releases: new Map([
['a', activeUndecidedErrorRelease],
['b', activeUndecidedErrorRelease],
]),
state: 'loaded',
},
2,
],
[
{
releases: new Map([
['a', activeUndecidedErrorRelease],
['b', activeUndecidedErrorRelease],
['c', archivedScheduledRelease],
['d', publishedASAPRelease],
]),
state: 'loaded',
},
NO_EMISSION,
],
[
{
releases: new Map([
['a', activeUndecidedErrorRelease],
['b', activeUndecidedErrorRelease],
['c', publishedASAPRelease],
['d', activeUndecidedErrorRelease],
['e', activeUndecidedErrorRelease],
['f', scheduledRelease],
]),
state: 'loaded',
},
4,
],
[
{
releases: new Map(),
state: 'loaded',
},
0,
],
[
{
releases: new Map(),
state: 'loaded',
},
NO_EMISSION,
],
])
})
})
31 changes: 30 additions & 1 deletion packages/sanity/src/core/releases/store/createReleaseStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,21 @@ import {
catchError,
concat,
concatWith,
count,
filter,
from,
merge,
type Observable,
of,
type OperatorFunction,
pipe,
scan,
shareReplay,
Subject,
switchMap,
tap,
} from 'rxjs'
import {map, startWith} from 'rxjs/operators'
import {distinctUntilChanged, map, startWith} from 'rxjs/operators'

import {type DocumentPreviewStore} from '../../preview'
import {listenQuery} from '../../store/_legacy'
Expand Down Expand Up @@ -45,6 +49,12 @@ const QUERY_PROJECTION = `{
"title": "",
"releaseType": "${DEFAULT_RELEASE_TYPE}",
}),
// Content Lake initially encoded non-error states as {error: {message: ""}}. This projection
// ensures the error field only appears if the document has a non-empty error message.
...select(
juice49 marked this conversation as resolved.
Show resolved Hide resolved
length(error.message) > 0 => { error },
{}
),
}`

// Newest releases first
Expand Down Expand Up @@ -135,11 +145,30 @@ export function createReleaseStore(context: {
shareReplay(1),
)

const errorCount$ = state$.pipe(releaseStoreErrorCount(), shareReplay(1))

const getMetadataStateForSlugs$ = createReleaseMetadataAggregator(client)

return {
state$,
errorCount$,
getMetadataStateForSlugs$,
dispatch,
}
}

/**
* @internal
*/
export function releaseStoreErrorCount(): OperatorFunction<ReleasesReducerState, number> {
return pipe(
switchMap(({releases}) =>
from(releases.values()).pipe(
filter((release) => release.state === 'active'),
filter((release) => typeof release.error !== 'undefined'),
Copy link
Member

Choose a reason for hiding this comment

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

COULD: roll this as a single filter, then since the check on state active and typeof error in used elsewhere too, could abstract that as some util... thinking maybe if the api simplifies or changes in the future that makes it easier

Copy link
Contributor Author

@juice49 juice49 Feb 6, 2025

Choose a reason for hiding this comment

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

Agree it would make sense to abstract this to some guards:

function isActiveRelease(maybeActiveRelease): maybeActiveRelease is ReleaseDocument & { state: 'active' }
function isErrorRelease(maybeErrorRelease): maybeErrorRelease is ReleaseDocument & { error: { message: string } }

These could then be composed to abstract the logic around whether an error should be displayed.

count(),
),
),
distinctUntilChanged(),
)
}
8 changes: 8 additions & 0 deletions packages/sanity/src/core/releases/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ export interface ReleaseDocument extends SanityDocument {
_updatedAt: string
_rev: string
state: ReleaseState
error?: {
message: string
}
finalDocumentStates?: ReleaseFinalDocumentState[]
/**
* If defined, it takes precedence over the intendedPublishAt, the state should be 'scheduled'
Expand Down Expand Up @@ -78,6 +81,11 @@ export function isReleaseDocument(doc: unknown): doc is ReleaseDocument {
*/
export interface ReleaseStore {
state$: Observable<ReleasesReducerState>
/**
* Counts all loaded release documents that are in an active state and have an error recorded.
* This is determined by the presence of the `error` field in the release document.
*/
errorCount$: Observable<number>
getMetadataStateForSlugs$: (slugs: string[]) => Observable<MetadataWrapper>
dispatch: Dispatch<ReleasesReducerAction>
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import {PinFilledIcon, PinIcon} from '@sanity/icons'
import {ErrorOutlineIcon, PinFilledIcon, PinIcon} from '@sanity/icons'
import {
Box,
// Custom button with full radius used here
// eslint-disable-next-line no-restricted-imports
Button,
Card,
Container,
Flex,
Stack,
Text,
} from '@sanity/ui'
import {useCallback} from 'react'

import {ToneIcon} from '../../../../ui-components/toneIcon/ToneIcon'
import {TextWithTone} from '../../../components/textWithTone/TextWithTone'
import {Details} from '../../../form/components/Details'
import {useTranslation} from '../../../i18n'
import {usePerspective} from '../../../perspective/usePerspective'
import {useSetPerspective} from '../../../perspective/useSetPerspective'
Expand All @@ -28,6 +33,7 @@ export function ReleaseDashboardDetails({release}: {release: ReleaseDocument}) {
const {selectedReleaseId} = usePerspective()
const setPerspective = useSetPerspective()
const isSelected = releaseId === selectedReleaseId
const shouldDisplayError = release.state === 'active' && typeof release.error !== 'undefined'

const handlePinRelease = useCallback(() => {
if (isSelected) {
Expand All @@ -40,7 +46,7 @@ export function ReleaseDashboardDetails({release}: {release: ReleaseDocument}) {
return (
<Container width={3}>
<Stack padding={3} paddingY={[4, 4, 5, 6]} space={[3, 3, 4, 5]}>
<Flex gap={1}>
<Flex gap={1} align="center">
<Button
disabled={state === 'archived' || state === 'published'}
icon={isSelected ? PinFilledIcon : PinIcon}
Expand All @@ -54,11 +60,37 @@ export function ReleaseDashboardDetails({release}: {release: ReleaseDocument}) {
tone={getReleaseTone(release)}
/>
<ReleaseTypePicker release={release} />
{shouldDisplayError && (
<Flex gap={2} padding={2} data-testid="release-error-details">
<Text size={1}>
<ToneIcon icon={ErrorOutlineIcon} tone="critical" />
</Text>
<TextWithTone size={1} tone="critical">
{tRelease('failed-publish-title')}
</TextWithTone>
</Flex>
)}
</Flex>

<Box padding={2}>
<ReleaseDetailsEditor release={release} />
</Box>
{shouldDisplayError && (
<Card padding={4} radius={4} tone="critical">
<Flex gap={3}>
<Text size={1}>
<ErrorOutlineIcon />
</Text>
<Stack space={4}>
<Text>{tRelease('failed-publish-title')}</Text>
<Details title={tRelease('error-details-title')}>
<Text>
<code>{release.error?.message}</code>
</Text>
</Details>
</Stack>
</Flex>
</Card>
)}
</Stack>
</Container>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'

import {mockUseRouterReturn} from '../../../../../../test/mocks/useRouter.mock'
import {createTestProvider} from '../../../../../../test/testUtils/TestProvider'
import {activeASAPRelease, publishedASAPRelease} from '../../../__fixtures__/release.fixture'
import {
activeASAPRelease,
activeUndecidedErrorRelease,
publishedASAPRelease,
} from '../../../__fixtures__/release.fixture'
import {releasesUsEnglishLocaleBundle} from '../../../i18n'
import {
mockUseActiveReleases,
Expand Down Expand Up @@ -326,4 +330,25 @@ describe('after releases have loaded', () => {
screen.getByText(activeASAPRelease.metadata.title)
})
})

describe('with release in error state', () => {
beforeEach(async () => {
mockUseActiveReleases.mockReset()

mockUseActiveReleases.mockReturnValue({
...useActiveReleasesMockReturn,
data: [activeUndecidedErrorRelease],
})

mockUseRouterReturn.state = {
releaseId: getReleaseIdFromReleaseDocumentId(activeUndecidedErrorRelease._id),
}

await renderTest()
})

it('should show error message', () => {
screen.getByTestId('release-error-details')
})
})
})
Loading
Loading