From 2fcba914f783e41c1ccd4c5dd0b8c64d4409bdc0 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 18 Jun 2026 13:37:49 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=97=20fix:=20Surface=20Share=20Permiss?= =?UTF-8?q?ions=20Load=20Error=20as=20Alert=20Button=20With=20Tooltip=20(#?= =?UTF-8?q?13833)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sharing/GenericGrantAccessDialog.tsx | 35 ++++- .../GenericGrantAccessDialog.spec.tsx | 136 ++++++++++++++++++ .../Sharing/useResourcePermissionState.ts | 4 + 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 client/src/components/Sharing/__tests__/GenericGrantAccessDialog.spec.tsx diff --git a/client/src/components/Sharing/GenericGrantAccessDialog.tsx b/client/src/components/Sharing/GenericGrantAccessDialog.tsx index 24e9d1ae9b..e28d58a7ba 100644 --- a/client/src/components/Sharing/GenericGrantAccessDialog.tsx +++ b/client/src/components/Sharing/GenericGrantAccessDialog.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { AccessRoleIds, ResourceType } from 'librechat-data-provider'; -import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck } from 'lucide-react'; +import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck, AlertCircle } from 'lucide-react'; import { Label, Button, @@ -11,6 +11,7 @@ import { OGDialogClose, OGDialogContent, OGDialogTrigger, + TooltipAnchor, useToastContext, } from '@librechat/client'; import type { TPrincipal } from 'librechat-data-provider'; @@ -60,7 +61,9 @@ export default function GenericGrantAccessDialog({ config, permissionsData, isLoadingPermissions, + isFetchingPermissions, permissionsError, + refetchPermissions, updatePermissionsMutation, currentShares, currentIsPublic, @@ -237,9 +240,35 @@ export default function GenericGrantAccessDialog({ const hasPublicChanges = isPublic !== currentIsPublic || publicRole !== currentPublicRole; const submitButtonActive = hasChanges || hasPublicChanges; - // Error handling + // On permissions load failure, keep a compact trigger-sized button so the layout holds, + // surfacing the error (and a retry on click) through a tooltip. if (permissionsError) { - return
{localize('com_ui_permissions_failed_load')}
; + return ( + refetchPermissions()} + aria-label={localize('com_ui_permissions_failed_load')} + className={cn('h-9', buttonClassName)} + > +
+ + {isFetchingPermissions ? ( + + ) : ( + +
+ + } + /> + ); } const TriggerComponent = children ? ( diff --git a/client/src/components/Sharing/__tests__/GenericGrantAccessDialog.spec.tsx b/client/src/components/Sharing/__tests__/GenericGrantAccessDialog.spec.tsx new file mode 100644 index 0000000000..f91667860d --- /dev/null +++ b/client/src/components/Sharing/__tests__/GenericGrantAccessDialog.spec.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { ResourceType } from 'librechat-data-provider'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import GenericGrantAccessDialog from '../GenericGrantAccessDialog'; + +const mockRefetchPermissions = jest.fn(); +const mockUseResourcePermissionState = jest.fn(); + +const config = { + defaultViewerRoleId: 'viewer', + defaultOwnerRoleId: 'owner', + getShareMessage: () => 'Share Agent', + getResourceUrl: () => 'http://localhost/agent/1', + getCopyUrlMessage: () => 'Copied', +}; + +const baseState = (overrides: Record = {}) => ({ + config, + permissionsData: { principals: [], public: false }, + isLoadingPermissions: false, + isFetchingPermissions: false, + permissionsError: null, + refetchPermissions: mockRefetchPermissions, + updatePermissionsMutation: { isLoading: false, mutateAsync: jest.fn() }, + currentShares: [], + currentIsPublic: false, + currentPublicRole: 'viewer', + isPublic: false, + setIsPublic: jest.fn(), + publicRole: 'viewer', + setPublicRole: jest.fn(), + ...overrides, +}); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useResourcePermissionState: () => mockUseResourcePermissionState(), + usePeoplePickerPermissions: () => ({ hasPeoplePickerAccess: true, peoplePickerTypeFilter: '' }), + useCanSharePublic: () => true, + useCopyToClipboard: () => jest.fn(), +})); + +jest.mock('@librechat/client', () => ({ + ...jest.requireActual('@librechat/client'), + useToastContext: () => ({ showToast: jest.fn() }), +})); + +jest.mock('../PeoplePicker/UnifiedPeopleSearch', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('../PeoplePickerAdminSettings', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('../PublicSharingToggle', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('../PeoplePicker', () => ({ + __esModule: true, + SelectedPrincipalsList: () =>
, +})); + +const renderDialog = () => + render( + , + ); + +describe('GenericGrantAccessDialog - permissions load failure', () => { + beforeEach(() => { + mockUseResourcePermissionState.mockReset(); + mockRefetchPermissions.mockReset(); + }); + + it('renders a compact alert button (not the share trigger, not raw text) when permissions fail to load', () => { + mockUseResourcePermissionState.mockReturnValue( + baseState({ permissionsError: new Error('boom'), permissionsData: undefined }), + ); + + const { container } = renderDialog(); + + expect( + screen.getByRole('button', { name: 'com_ui_permissions_failed_load' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'com_ui_share_var' })).not.toBeInTheDocument(); + expect(container.querySelector('.spinner')).not.toBeInTheDocument(); + }); + + it('retries the permissions fetch when the alert button is clicked', () => { + mockUseResourcePermissionState.mockReturnValue( + baseState({ permissionsError: new Error('boom'), permissionsData: undefined }), + ); + + renderDialog(); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_permissions_failed_load' })); + expect(mockRefetchPermissions).toHaveBeenCalledTimes(1); + }); + + it('shows a spinner instead of the alert icon while refetching', () => { + mockUseResourcePermissionState.mockReturnValue( + baseState({ + permissionsError: new Error('boom'), + permissionsData: undefined, + isFetchingPermissions: true, + }), + ); + + const { container } = renderDialog(); + + expect( + screen.getByRole('button', { name: 'com_ui_permissions_failed_load' }), + ).toBeInTheDocument(); + expect(container.querySelector('.spinner')).toBeInTheDocument(); + }); + + it('renders the normal share trigger and dialog body when permissions load successfully', () => { + mockUseResourcePermissionState.mockReturnValue(baseState()); + + renderDialog(); + + expect( + screen.queryByRole('button', { name: 'com_ui_permissions_failed_load' }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_share_var' })); + expect(screen.getByTestId('unified-people-search')).toBeInTheDocument(); + }); +}); diff --git a/client/src/hooks/Sharing/useResourcePermissionState.ts b/client/src/hooks/Sharing/useResourcePermissionState.ts index 0a9f8ce386..875fe17832 100644 --- a/client/src/hooks/Sharing/useResourcePermissionState.ts +++ b/client/src/hooks/Sharing/useResourcePermissionState.ts @@ -26,7 +26,9 @@ export const useResourcePermissionState = ( const { data: permissionsData, isLoading: isLoadingPermissions, + isFetching: isFetchingPermissions, error: permissionsError, + refetch: refetchPermissions, } = useGetResourcePermissionsQuery(resourceType, resourceDbId || '', { enabled: isValidResourceId, }); @@ -68,7 +70,9 @@ export const useResourcePermissionState = ( config, permissionsData, isLoadingPermissions, + isFetchingPermissions, permissionsError, + refetchPermissions, updatePermissionsMutation, currentShares, currentIsPublic,