mirror of https://github.com/grafana/grafana
Invite User: Add invite user button to top nav for admins (#108159)
* reimplement original design * update button with responsive design that matches other elements in top bar; add comprehensive unit tests * add a basic call out in docs for new button * remove doc changesjh/test-commit-signing
parent
88ec253ad5
commit
554e92c408
@ -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(<InviteUserButton />); |
||||||
|
|
||||||
|
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(<InviteUserButton />); |
||||||
|
|
||||||
|
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(<InviteUserButton />); |
||||||
|
|
||||||
|
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 (<lg)
|
||||||
|
|
||||||
|
render(<InviteUserButton />); |
||||||
|
|
||||||
|
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(<InviteUserButton />); |
||||||
|
|
||||||
|
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(<InviteUserButton />); |
||||||
|
|
||||||
|
// 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(<InviteUserButton />); |
||||||
|
|
||||||
|
// 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(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -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 ? ( |
||||||
|
<> |
||||||
|
<ToolbarButton |
||||||
|
icon="add-user" |
||||||
|
iconOnly={!isLargeScreen} |
||||||
|
onClick={handleClick} |
||||||
|
tooltip={t('navigation.invite-user.invite-tooltip', 'Invite user')} |
||||||
|
aria-label={t('navigation.invite-user.invite-tooltip', 'Invite user')} |
||||||
|
> |
||||||
|
{isLargeScreen ? t('navigation.invite-user.invite-button', 'Invite') : undefined} |
||||||
|
</ToolbarButton> |
||||||
|
<NavToolbarSeparator /> |
||||||
|
</> |
||||||
|
) : null; |
||||||
|
} |
Loading…
Reference in new issue