diff --git a/src/__testing__/ShareModalWireContract.test.tsx b/src/__testing__/ShareModalWireContract.test.tsx index e1222954a..457474241 100644 --- a/src/__testing__/ShareModalWireContract.test.tsx +++ b/src/__testing__/ShareModalWireContract.test.tsx @@ -188,4 +188,63 @@ describe('ShareModal resource-access wire contract', () => { ); expect(resourceAccessMutator).not.toHaveBeenCalled(); }); + + it('surfaces host error text when visibility update fails with a string error', async () => { + const handleUpdateVisibility = jest + .fn() + .mockResolvedValue({ error: 'visibility rejected' }); + const { props } = renderShareModal({ + handleUpdateVisibility + } as Partial); + + fireEvent.mouseDown(document.getElementById('share-menu')!); + fireEvent.click(screen.getByRole('option', { name: 'Public' })); + + await waitFor(() => expect(handleUpdateVisibility).toHaveBeenCalledWith('public')); + await waitFor(() => + expect(props.notify).toHaveBeenCalledWith({ + message: 'Failed to update visibility. visibility rejected', + event_type: 'error' + }) + ); + }); + + it('surfaces nested RTK error text when visibility update returns an error object', async () => { + const handleUpdateVisibility = jest + .fn() + .mockResolvedValue({ error: { error: 'permission denied' } }); + const { props } = renderShareModal({ + handleUpdateVisibility + } as Partial); + + fireEvent.mouseDown(document.getElementById('share-menu')!); + fireEvent.click(screen.getByRole('option', { name: 'Public' })); + + await waitFor(() => expect(handleUpdateVisibility).toHaveBeenCalledWith('public')); + await waitFor(() => + expect(props.notify).toHaveBeenCalledWith({ + message: 'Failed to update visibility. permission denied', + event_type: 'error' + }) + ); + }); + + it('notifies success when visibility update succeeds', async () => { + const handleUpdateVisibility = jest.fn().mockResolvedValue({ error: '' }); + const { props } = renderShareModal({ + handleUpdateVisibility + } as Partial); + + fireEvent.mouseDown(document.getElementById('share-menu')!); + fireEvent.click(screen.getByRole('option', { name: 'Public' })); + + await waitFor(() => expect(handleUpdateVisibility).toHaveBeenCalledWith('public')); + await waitFor(() => + expect(props.notify).toHaveBeenCalledWith({ + message: "Design 'My Design' is now public", + event_type: 'success' + }) + ); + }); }); + diff --git a/src/custom/ShareModal/ShareModal.tsx b/src/custom/ShareModal/ShareModal.tsx index 8045d6694..7d92fdf24 100644 --- a/src/custom/ShareModal/ShareModal.tsx +++ b/src/custom/ShareModal/ShareModal.tsx @@ -174,6 +174,30 @@ export type ResourceAccessArg = { resourceAccessMappingPayload: ResourceAccessMappingPayload; }; +export type VisibilityUpdateError = + | string + | { + error?: string; + message?: string; + data?: + | { + message?: string; + [key: string]: unknown; + } + | string; + [key: string]: unknown; + }; + +export type VisibilityUpdateResponse = + | { + error?: VisibilityUpdateError; + data?: unknown; + [key: string]: unknown; + } + | void + | null + | undefined; + export interface ShareModalProps { /** Function to close the share modal */ handleShareModalClose: () => void; @@ -187,7 +211,7 @@ export interface ShareModalProps { fetchAccessActors: () => Promise; /** Optional URL of the host application. Defaults to `null` if not provided */ hostURL?: string | null; - handleUpdateVisibility: (value: string) => Promise<{ error: string }>; + handleUpdateVisibility: (value: string) => Promise; /** * @deprecated Unused - never read. The component defines its own * `handleShareWithNewUsers`, which shadows this prop and shares through @@ -420,14 +444,28 @@ const ShareModal: React.FC = ({ const handleDelete = async (actor: User) => handleRevokeAccess([actor]); - /* eslint-disable @typescript-eslint/no-explicit-any */ - const notifyVisibilityChange = (res: any, value: any) => { + const notifyVisibilityChange = (res: VisibilityUpdateResponse, value: string) => { const UPDATE_VISIBILITY_MSG = Array.isArray(selectedResource) ? `${startCase(dataName)}s (${selectedResource.length}) are now ${value}` : `${startCase(dataName)} '${selectedResource.name}' is now ${value}`; - const FAILED_TO_UPDATE_VISIBILITY_MSG = `Failed to update visibility. ${res?.error?.error || ''}`; - - if (!res.error) { + const err = res && typeof res === 'object' && 'error' in res ? res.error : undefined; + const detail = + typeof err === 'string' + ? err + : typeof err === 'object' && err !== null + ? typeof err.error === 'string' + ? err.error + : typeof err.data === 'object' && err.data !== null && typeof err.data.message === 'string' + ? err.data.message + : typeof err.message === 'string' + ? err.message + : '' + : ''; + const FAILED_TO_UPDATE_VISIBILITY_MSG = detail + ? `Failed to update visibility. ${detail}` + : 'Failed to update visibility.'; + + if (!err) { notify({ message: UPDATE_VISIBILITY_MSG, event_type: 'success' @@ -452,7 +490,8 @@ const ShareModal: React.FC = ({ setUpdatingVisibility(true); const res = await handleUpdateVisibility(value); notifyVisibilityChange(res, value); - if (!res?.error) { + const err = res && typeof res === 'object' && 'error' in res ? res.error : undefined; + if (!err) { setVisibility(value); } } finally { diff --git a/src/custom/ShareModal/index.tsx b/src/custom/ShareModal/index.tsx index e809d6422..b6915e1e5 100644 --- a/src/custom/ShareModal/index.tsx +++ b/src/custom/ShareModal/index.tsx @@ -8,5 +8,10 @@ export { type ResourceAccessActor, type ResourceAccessMappingPayload } from './resourceAccessPayload'; -export type { ResourceAccessArg, ShareModalProps } from './ShareModal'; +export type { + ResourceAccessArg, + ShareModalProps, + VisibilityUpdateError, + VisibilityUpdateResponse +} from './ShareModal'; export { ShareModal }; diff --git a/src/index.tsx b/src/index.tsx index d104a9220..300b12218 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -140,7 +140,9 @@ export { type ResourceAccessActor, type ResourceAccessArg, type ResourceAccessMappingPayload, - type ShareModalProps + type ShareModalProps, + type VisibilityUpdateError, + type VisibilityUpdateResponse } from './custom/ShareModal'; // Same nested-barrel dts-drop quirk as FeedbackButton above, and the whole