diff --git a/packages/sanity/package.json b/packages/sanity/package.json index e5a815e4034..ed7568bcfe5 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -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", diff --git a/packages/sanity/src/core/perspective/ReleasesToolLink.tsx b/packages/sanity/src/core/perspective/ReleasesToolLink.tsx index b4f5a213631..5e53dc3f935 100644 --- a/packages/sanity/src/core/perspective/ReleasesToolLink.tsx +++ b/packages/sanity/src/core/perspective/ReleasesToolLink.tsx @@ -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( @@ -24,7 +48,7 @@ export function ReleasesToolLink(): React.JSX.Element { ) return ( - + - + ) } diff --git a/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts index 74f64fd6559..b939335fc54 100644 --- a/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts +++ b/packages/sanity/src/core/releases/__fixtures__/release.fixture.ts @@ -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.', + }, +} diff --git a/packages/sanity/src/core/releases/i18n/resources.ts b/packages/sanity/src/core/releases/i18n/resources.ts index 3bfc94962be..db8f00b04d0 100644 --- a/packages/sanity/src/core/releases/i18n/resources.ts +++ b/packages/sanity/src/core/releases/i18n/resources.ts @@ -141,8 +141,13 @@ const releasesLocaleStrings = { /** Label when a release has been deleted by a different user */ 'deleted-release': "The '{{title}}' 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 */ diff --git a/packages/sanity/src/core/releases/store/__tests__/createsReleaseStore.test.ts b/packages/sanity/src/core/releases/store/__tests__/createsReleaseStore.test.ts new file mode 100644 index 00000000000..bcfe1de3b88 --- /dev/null +++ b/packages/sanity/src/core/releases/store/__tests__/createsReleaseStore.test.ts @@ -0,0 +1,75 @@ +import {describe, expect, it} from 'vitest' + +import {NO_EMISSION} from '../../../../../test/matchers/toMatchEmissions' +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, + ], + ]) + }) +}) diff --git a/packages/sanity/src/core/releases/store/createReleaseStore.ts b/packages/sanity/src/core/releases/store/createReleaseStore.ts index 250d09afa32..ebbd1fd2777 100644 --- a/packages/sanity/src/core/releases/store/createReleaseStore.ts +++ b/packages/sanity/src/core/releases/store/createReleaseStore.ts @@ -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' @@ -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( + length(error.message) > 0 => { error }, + {} + ), }` // Newest releases first @@ -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 { + return pipe( + switchMap(({releases}) => + from(releases.values()).pipe( + filter((release) => release.state === 'active'), + filter((release) => typeof release.error !== 'undefined'), + count(), + ), + ), + distinctUntilChanged(), + ) +} diff --git a/packages/sanity/src/core/releases/store/types.ts b/packages/sanity/src/core/releases/store/types.ts index 36dcdc21e1c..b44b9ca0d0f 100644 --- a/packages/sanity/src/core/releases/store/types.ts +++ b/packages/sanity/src/core/releases/store/types.ts @@ -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' @@ -78,6 +81,11 @@ export function isReleaseDocument(doc: unknown): doc is ReleaseDocument { */ export interface ReleaseStore { state$: Observable + /** + * 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 getMetadataStateForSlugs$: (slugs: string[]) => Observable dispatch: Dispatch } diff --git a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx index 443b942f2b5..e58c68f729d 100644 --- a/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx +++ b/packages/sanity/src/core/releases/tool/detail/ReleaseDashboardDetails.tsx @@ -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' @@ -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) { @@ -40,7 +46,7 @@ export function ReleaseDashboardDetails({release}: {release: ReleaseDocument}) { return ( - +