Skip to content

Commit 7809762

Browse files
committed
feat(sanity): add ErrorActions component
1 parent 61e981b commit 7809762

File tree

7 files changed

+191
-0
lines changed

7 files changed

+191
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {CopyIcon, SyncIcon} from '@sanity/icons'
2+
import {Inline} from '@sanity/ui'
3+
import {type ComponentProps, type ComponentType} from 'react'
4+
5+
import {Button, Tooltip} from '../../../ui-components'
6+
import {strings} from './strings'
7+
import {useCopyErrorDetails} from './useCopyErrorDetails'
8+
9+
/**
10+
* @internal
11+
*/
12+
export interface ErrorActionsProps extends Pick<ComponentProps<typeof Button>, 'size'> {
13+
error: unknown
14+
eventId: string | null
15+
onRetry?: () => void
16+
}
17+
18+
/**
19+
* @internal
20+
*/
21+
export const ErrorActions: ComponentType<ErrorActionsProps> = ({error, eventId, onRetry, size}) => {
22+
const copyErrorDetails = useCopyErrorDetails(error, eventId)
23+
24+
return (
25+
<Inline space={3}>
26+
{onRetry && (
27+
<Button
28+
onClick={onRetry}
29+
text={strings['retry.title']}
30+
tone="primary"
31+
icon={SyncIcon}
32+
size={size}
33+
/>
34+
)}
35+
<Tooltip content={strings['copy-error-details.description']}>
36+
<Button
37+
onClick={copyErrorDetails}
38+
text={strings['copy-error-details.title']}
39+
tone="default"
40+
mode="ghost"
41+
icon={CopyIcon}
42+
size={size}
43+
/>
44+
</Tooltip>
45+
</Inline>
46+
)
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './ErrorActions'
2+
export * from './types'
3+
export * from './useCopyErrorDetails'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// These strings are not internationalized because `ErrorActions` is used inside
2+
// `StudioErrorBoundary`, which is rendered outside of `LocaleProvider`.
3+
export const strings = {
4+
'retry.title': 'Retry',
5+
'copy-error-details.description': 'These technical details may be useful for developers.',
6+
'copy-error-details.title': 'Copy error details',
7+
'copy-error-details.toast.get-failed': 'Failed to get error details',
8+
'copy-error-details.toast.copy-failed': 'Failed to copy error details',
9+
'copy-error-details.toast.succeeded': 'Copied error details to clipboard',
10+
} as const
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* @internal
3+
*/
4+
export interface ErrorWithId {
5+
error: unknown
6+
eventId?: string | null
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {describe, expect, it} from '@jest/globals'
2+
import {firstValueFrom, map, of} from 'rxjs'
3+
4+
import {type ErrorWithId} from './types'
5+
import {serializeError} from './useCopyErrorDetails'
6+
7+
describe('serializeError', () => {
8+
it('includes error properties if an instance of `Error` is provided', async () => {
9+
const error = await reassembleError({
10+
error: new Error('Test', {
11+
cause: 'Unit test',
12+
}),
13+
})
14+
15+
expect((error.error as any).message).toBe('Test')
16+
expect((error.error as any).cause).toBe('Unit test')
17+
})
18+
19+
it('includes record-like errors', async () => {
20+
const error = await reassembleError({
21+
error: {
22+
someProperty: 'someValue',
23+
},
24+
})
25+
26+
expect((error.error as any).someProperty).toBe('someValue')
27+
})
28+
29+
const nonRecordCases = [123, 'someString', ['some', 'array'], Symbol('Some error')]
30+
31+
it.each(nonRecordCases)('does not include non-record errors', async (errorCase) => {
32+
const {error} = await reassembleError({
33+
error: errorCase,
34+
})
35+
expect(error).toBeUndefined()
36+
})
37+
38+
it('includes event id if one is provided', async () => {
39+
const {eventId} = await reassembleError({
40+
error: new Error(),
41+
eventId: '123',
42+
})
43+
44+
expect(eventId).toBe('123')
45+
})
46+
})
47+
48+
/**
49+
* Helper that serializes and then immediately deserializes the provided error so that assertions
50+
* about the serialization process can be made.
51+
*/
52+
function reassembleError(error: ErrorWithId): Promise<ErrorWithId> {
53+
return firstValueFrom(
54+
of(error).pipe(
55+
serializeError(),
56+
map((serializedError) => JSON.parse(serializedError)),
57+
),
58+
)
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import {useToast} from '@sanity/ui'
2+
import {pick} from 'lodash'
3+
import {useCallback} from 'react'
4+
import {catchError, EMPTY, map, of, type OperatorFunction, tap} from 'rxjs'
5+
6+
import {isRecord} from '../../util'
7+
import {strings} from './strings'
8+
import {type ErrorWithId} from './types'
9+
10+
const TOAST_ID = 'copyErrorDetails'
11+
12+
/**
13+
* @internal
14+
*/
15+
export function useCopyErrorDetails(error: unknown, eventId?: string | null): () => void {
16+
const toast = useToast()
17+
18+
return useCallback(() => {
19+
of<ErrorWithId>({error, eventId})
20+
.pipe(
21+
serializeError(),
22+
catchError((serializeErrorError) => {
23+
console.error(serializeErrorError)
24+
toast.push({
25+
status: 'error',
26+
title: strings['copy-error-details.toast.get-failed'],
27+
id: TOAST_ID,
28+
})
29+
return EMPTY
30+
}),
31+
tap((errorDetailsString) => {
32+
navigator.clipboard.writeText(errorDetailsString)
33+
toast.push({
34+
status: 'success',
35+
title: strings['copy-error-details.toast.succeeded'],
36+
id: TOAST_ID,
37+
})
38+
}),
39+
catchError((copyErrorError) => {
40+
console.error(copyErrorError)
41+
toast.push({
42+
status: 'error',
43+
title: strings['copy-error-details.toast.copy-failed'],
44+
id: TOAST_ID,
45+
})
46+
return EMPTY
47+
}),
48+
)
49+
.subscribe()
50+
}, [error, eventId, toast])
51+
}
52+
53+
/**
54+
* @internal
55+
*/
56+
export function serializeError(): OperatorFunction<ErrorWithId, string> {
57+
return map<ErrorWithId, string>(({error, eventId}) => {
58+
// Extract the non-enumerable properties of the provided `error` object. This is particularly
59+
// useful if the provided `error` value is an instance of `Error`, whose properties are
60+
// non-enumerable.
61+
const errorInfo = isRecord(error) ? pick(error, Object.getOwnPropertyNames(error)) : undefined
62+
return JSON.stringify({error: errorInfo, eventId}, null, 2)
63+
})
64+
}

packages/sanity/src/core/components/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export * from './contextMenuButton'
1010
export * from './DefaultDocument'
1111
export * from './documentStatus'
1212
export * from './documentStatusIndicator'
13+
export * from './errorActions'
1314
export * from './globalErrorHandler'
1415
export * from './hookCollection'
1516
export * from './Hotkeys'

0 commit comments

Comments
 (0)