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
pull/108111/head
Nathan Marrs 22 hours ago committed by GitHub
parent 88ec253ad5
commit 554e92c408
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 174
      public/app/core/components/AppChrome/TopBar/InviteUserButton.test.tsx
  2. 43
      public/app/core/components/AppChrome/TopBar/InviteUserButton.tsx
  3. 2
      public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx
  4. 4
      public/locales/en-US/grafana.json

@ -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",

Loading…
Cancel
Save