diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx new file mode 100644 index 00000000000..d867c17488b --- /dev/null +++ b/public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx @@ -0,0 +1,174 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { config, reportInteraction } from '@grafana/runtime'; +import { contextSrv } from 'app/core/core'; +import { getExternalUserMngLinkUrl } from 'app/features/users/utils'; + +import { InviteUserButton } from './InviteUserButton'; + +// Mock dependencies +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + externalUserMngLinkUrl: 'https://example.com/invite', + }, + reportInteraction: jest.fn(), +})); + +jest.mock('app/core/core', () => ({ + contextSrv: { + hasPermission: jest.fn(), + }, +})); + +jest.mock('app/features/users/utils', () => ({ + getExternalUserMngLinkUrl: jest.fn(), +})); + +const mockContextSrv = jest.mocked(contextSrv); +const mockConfig = jest.mocked(config); +const mockReportInteraction = jest.mocked(reportInteraction); +const mockGetExternalUserMngLinkUrl = jest.mocked(getExternalUserMngLinkUrl); + +// Mock window.open +const mockWindowOpen = jest.fn(); +Object.defineProperty(window, 'open', { + value: mockWindowOpen, + writable: true, +}); + +// Mock window.matchMedia for responsive testing +const mockMatchMedia = (matches: boolean) => { + Object.defineProperty(window, 'matchMedia', { + value: jest.fn().mockImplementation(() => ({ + matches, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + })), + writable: true, + }); +}; + +describe('InviteUserButton', () => { + const mockInviteUrl = 'https://example.com/invite?cnt=invite-user-top-bar'; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetExternalUserMngLinkUrl.mockReturnValue(mockInviteUrl); + }); + + describe('Business Logic - When button should appear', () => { + it('should not render when user lacks permission', () => { + mockConfig.externalUserMngLinkUrl = 'https://example.com/invite'; + mockContextSrv.hasPermission.mockReturnValue(false); + mockMatchMedia(true); + + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('should not render when external user management URL is not configured', () => { + mockConfig.externalUserMngLinkUrl = ''; + mockContextSrv.hasPermission.mockReturnValue(true); + mockMatchMedia(true); + + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + }); + + describe('User Experience - Responsive behavior', () => { + beforeEach(() => { + mockConfig.externalUserMngLinkUrl = 'https://example.com/invite'; + mockContextSrv.hasPermission.mockReturnValue(true); + }); + + it('should show text on large screens', () => { + mockMatchMedia(true); // Large screen (≥lg) + + render(); + + const button = screen.getByRole('button', { name: /invite user/i }); + expect(button).toHaveTextContent('Invite'); + }); + + it('should show icon only on small screens', () => { + mockMatchMedia(false); // Small screen (); + + const button = screen.getByRole('button', { name: /invite user/i }); + expect(button).not.toHaveTextContent('Invite'); + }); + }); + + describe('Core Functionality - Click behavior', () => { + beforeEach(() => { + mockConfig.externalUserMngLinkUrl = 'https://example.com/invite'; + mockContextSrv.hasPermission.mockReturnValue(true); + mockMatchMedia(true); + }); + + it('should track analytics and open invite URL when clicked', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /invite user/i })); + + // Verify the complete user flow + expect(mockReportInteraction).toHaveBeenCalledWith('invite_user_button_clicked', { + placement: 'top_bar_right', + }); + expect(mockGetExternalUserMngLinkUrl).toHaveBeenCalledWith('invite-user-top-bar'); + expect(mockWindowOpen).toHaveBeenCalledWith(mockInviteUrl, '_blank'); + }); + }); + + describe('Error Handling - Preventing crashes', () => { + beforeEach(() => { + mockConfig.externalUserMngLinkUrl = 'https://example.com/invite'; + mockContextSrv.hasPermission.mockReturnValue(true); + mockMatchMedia(true); + }); + + it('should handle URL generation errors gracefully', async () => { + mockGetExternalUserMngLinkUrl.mockImplementation(() => { + throw new Error('URL generation failed'); + }); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(); + + // Should not crash when URL generation fails + await user.click(screen.getByRole('button', { name: /invite user/i })); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to handle invite user click:', expect.any(Error)); + + consoleSpy.mockRestore(); + }); + + it('should handle popup blocking gracefully', async () => { + mockWindowOpen.mockImplementation(() => { + throw new Error('Popup blocked'); + }); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(); + + // Should not crash when popup is blocked + await user.click(screen.getByRole('button', { name: /invite user/i })); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to handle invite user click:', expect.any(Error)); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx b/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx new file mode 100644 index 00000000000..deec044c42e --- /dev/null +++ b/public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx @@ -0,0 +1,43 @@ +import { t } from '@grafana/i18n'; +import { config, reportInteraction } from '@grafana/runtime'; +import { ToolbarButton } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; +import { getExternalUserMngLinkUrl } from 'app/features/users/utils'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; + +export function InviteUserButton() { + const isLargeScreen = useMediaQueryMinWidth('lg'); + + const handleClick = () => { + try { + reportInteraction('invite_user_button_clicked', { + placement: 'top_bar_right', + }); + + const url = getExternalUserMngLinkUrl('invite-user-top-bar'); + window.open(url.toString(), '_blank'); + } catch (error) { + console.error('Failed to handle invite user click:', error); + } + }; + + const shouldRender = config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd); + + return shouldRender ? ( + <> + + {isLargeScreen ? t('navigation.invite-user.invite-button', 'Invite') : undefined} + + + + ) : null; +} diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index 6766d757b49..e5033c75c1c 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -24,6 +24,7 @@ import { enrichHelpItem } from '../MegaMenu/utils'; import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator'; import { QuickAdd } from '../QuickAdd/QuickAdd'; +import { InviteUserButton } from './InviteUserButton'; import { ProfileButton } from './ProfileButton'; import { SignInLink } from './SignInLink'; import { SingleTopBarActions } from './SingleTopBarActions'; @@ -106,6 +107,7 @@ export const SingleTopBar = memo(function SingleTopBar({ {config.featureToggles.extensionSidebar && !isSmallScreen && } {!showToolbarLevel && actions} {!contextSrv.user.isSignedIn && } + {config.featureToggles.inviteUserExperimental && } {profileNode && } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index aeeb66f2cac..abf40fc23fe 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9759,7 +9759,9 @@ "aria-label": "Help" }, "invite-user": { - "invite-new-member-button": "Invite new member" + "invite-button": "Invite", + "invite-new-member-button": "Invite new member", + "invite-tooltip": "Invite user" }, "item": { "add-bookmark": "Add to Bookmarks",