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 changes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 && <ExtensionToolbarItem />}
|
||||
{!showToolbarLevel && actions}
|
||||
{!contextSrv.user.isSignedIn && <SignInLink />}
|
||||
{config.featureToggles.inviteUserExperimental && <InviteUserButton />}
|
||||
{profileNode && <ProfileButton profileNode={profileNode} onToggleKioskMode={onToggleKioskMode} />}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user