Invite User: Clean up previous experiment (#109214)

* remove invite user button from quick add menu as redundant with top nav invite user button

* clean up final invite user experiment

* remove invite user button from megamenu, clean up and consolidate usage of util functions across top nav and command palette

* add some basic tests to util functions

* fix tests / jest mock conflicts

* address PR feedback
This commit is contained in:
Nathan Marrs
2025-08-06 21:15:55 +00:00
committed by GitHub
parent d3af4b582f
commit c066254e67
11 changed files with 162 additions and 101 deletions
-1
View File
@@ -737,7 +737,6 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform
/public/app/core/components/GraphNG/ @grafana/dataviz-squad
/public/app/core/components/help/ @grafana/grafana-search-navigate-organise
/public/app/core/components/Indent/ @grafana/grafana-search-navigate-organise
/public/app/core/components/InviteUserButton/ @grafana/sharing-squad
/public/app/core/components/Layers/ @grafana/dataviz-squad
/public/app/core/components/Login/ @grafana/grafana-search-navigate-organise
/public/app/core/components/NativeScrollbar.tsx @grafana/grafana-search-navigate-organise
@@ -13,9 +13,6 @@ import { setBookmark } from 'app/core/reducers/navBarTree';
import { usePatchUserPreferencesMutation } from 'app/features/preferences/api/index';
import { useDispatch, useSelector } from 'app/types/store';
import { InviteUserButton } from '../../InviteUserButton/InviteUserButton';
import { shouldRenderInviteUserButton } from '../../InviteUserButton/utils';
import { MegaMenuHeader } from './MegaMenuHeader';
import { MegaMenuItem } from './MegaMenuItem';
import { usePinnedItems } from './hooks';
@@ -128,11 +125,6 @@ export const MegaMenu = memo(
))}
</ul>
</ScrollContainer>
{shouldRenderInviteUserButton && (
<div className={styles.inviteNewMemberButton}>
<InviteUserButton />
</div>
)}
</nav>
</div>
);
@@ -170,11 +162,6 @@ const getStyles = (theme: GrafanaTheme2) => {
width: MENU_WIDTH,
},
}),
inviteNewMemberButton: css({
display: 'flex',
padding: theme.spacing(1.5, 1, 1.5, 1),
borderTop: `1px solid ${theme.colors.border.weak}`,
}),
dockMenuButton: css({
display: 'none',
position: 'relative',
@@ -3,10 +3,8 @@ import { useMemo, useState } from 'react';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Menu, Dropdown, ToolbarButton } from '@grafana/ui';
import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
import { useSelector } from 'app/types/store';
import { performInviteUserClick, shouldRenderInviteUserButton } from '../../InviteUserButton/utils';
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
import { findCreateActions } from './utils';
@@ -17,23 +15,7 @@ export const QuickAdd = ({}: Props) => {
const navBarTree = useSelector((state) => state.navBarTree);
const [isOpen, setIsOpen] = useState(false);
const createActions = useMemo(() => {
const actions = findCreateActions(navBarTree);
if (shouldRenderInviteUserButton) {
actions.push({
text: t('navigation.invite-user.invite-new-member-button', 'Invite new member'),
url: getExternalUserMngLinkUrl('invite-user-top-bar'),
target: '_blank',
isCreateAction: true,
onClick: () => {
performInviteUserClick('quick_add_button', 'invite-user-quick-add-button');
},
});
}
return actions;
}, [navBarTree]);
const createActions = useMemo(() => findCreateActions(navBarTree), [navBarTree]);
const showQuickAdd = createActions.length > 0;
if (!showQuickAdd) {
@@ -44,18 +26,12 @@ export const QuickAdd = ({}: Props) => {
return (
<Menu>
{createActions.map((createAction, index) => (
<div key={index}>
{shouldRenderInviteUserButton && index === createActions.length - 1 && <Menu.Divider />}
<Menu.Item
url={createAction.url}
label={createAction.text}
target={createAction.target}
onClick={() => {
reportInteraction('grafana_menu_item_clicked', { url: createAction.url, from: 'quickadd' });
createAction.onClick?.();
}}
/>
</div>
<Menu.Item
key={index}
url={createAction.url}
label={createAction.text}
onClick={() => reportInteraction('grafana_menu_item_clicked', { url: createAction.url, from: 'quickadd' })}
/>
))}
</Menu>
);
@@ -11,6 +11,9 @@ import { InviteUserButton } from './InviteUserButton';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
featureToggles: {
inviteUserExperimental: true,
},
externalUserMngLinkUrl: 'https://example.com/invite',
},
reportInteraction: jest.fn(),
@@ -1,43 +1,36 @@
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';
import { performInviteUserClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
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');
performInviteUserClick('top_bar_right', 'invite-user-top-bar');
} 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;
return (
shouldRenderInviteUserButton() && (
<>
<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 />
</>
)
);
}
@@ -0,0 +1,122 @@
import type { FeatureToggles } from '@grafana/data';
import { reportInteraction, config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
import { AccessControlAction } from 'app/types/accessControl';
import { performInviteUserClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
// Mock dependencies
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
config: {
featureToggles: {} as Partial<FeatureToggles>,
externalUserMngLinkUrl: '',
},
}));
jest.mock('app/core/core', () => ({
contextSrv: {
hasPermission: jest.fn(),
},
}));
jest.mock('app/features/users/utils', () => ({
getExternalUserMngLinkUrl: jest.fn(),
}));
const mockReportInteraction = jest.mocked(reportInteraction);
const mockConfig = jest.mocked(config);
const mockContextSrv = jest.mocked(contextSrv);
const mockGetExternalUserMngLinkUrl = jest.mocked(getExternalUserMngLinkUrl);
// Type assertion to make mockConfig.featureToggles assignable
const mockFeatureToggles = mockConfig.featureToggles as Partial<FeatureToggles>;
// Mock window.open
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', {
value: mockWindowOpen,
writable: true,
});
describe('InviteUserButtonUtils', () => {
beforeEach(() => {
jest.clearAllMocks();
// Set up default mocks
mockFeatureToggles.inviteUserExperimental = true;
mockConfig.externalUserMngLinkUrl = 'https://example.com/invite';
mockContextSrv.hasPermission.mockReturnValue(true);
// Dynamic mock that returns URL with the actual cnt parameter
mockGetExternalUserMngLinkUrl.mockImplementation((cnt: string) => `https://example.com/invite?cnt=${cnt}`);
});
describe('shouldRenderInviteUserButton', () => {
it('should return true when all conditions are met', () => {
expect(shouldRenderInviteUserButton()).toBe(true);
expect(mockContextSrv.hasPermission).toHaveBeenCalledWith(AccessControlAction.OrgUsersAdd);
});
it('should return false when feature toggle is disabled', () => {
mockFeatureToggles.inviteUserExperimental = false;
expect(shouldRenderInviteUserButton()).toBe(false);
});
it('should return false when URL is not configured', () => {
mockConfig.externalUserMngLinkUrl = '';
expect(shouldRenderInviteUserButton()).toBeFalsy();
});
it('should return false when user lacks permission', () => {
mockContextSrv.hasPermission.mockReturnValue(false);
expect(shouldRenderInviteUserButton()).toBe(false);
});
});
describe('performInviteUserClick', () => {
it('should report interaction, get URL, and open window with correct cnt parameter', () => {
const placement = 'top_bar_right';
const cnt = 'invite-user-test';
performInviteUserClick(placement, cnt);
expect(mockReportInteraction).toHaveBeenCalledWith('invite_user_button_clicked', {
placement,
});
expect(mockGetExternalUserMngLinkUrl).toHaveBeenCalledWith(cnt);
expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com/invite?cnt=${cnt}`, '_blank');
});
it('should work with different cnt values', () => {
const placement = 'top_bar_right';
const cnt = 'dashboard-header';
performInviteUserClick(placement, cnt);
expect(mockReportInteraction).toHaveBeenCalledWith('invite_user_button_clicked', {
placement,
});
expect(mockGetExternalUserMngLinkUrl).toHaveBeenCalledWith(cnt);
expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com/invite?cnt=${cnt}`, '_blank');
});
it('should handle special characters in cnt parameter', () => {
const placement = 'menu-item';
const cnt = 'admin-panel_users-section';
performInviteUserClick(placement, cnt);
expect(mockReportInteraction).toHaveBeenCalledWith('invite_user_button_clicked', {
placement,
});
expect(mockGetExternalUserMngLinkUrl).toHaveBeenCalledWith(cnt);
expect(mockWindowOpen).toHaveBeenCalledWith(`https://example.com/invite?cnt=${cnt}`, '_blank');
});
});
});
@@ -1,10 +1,9 @@
import { reportInteraction } from '@grafana/runtime';
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { reportInteraction, config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
import { AccessControlAction } from 'app/types/accessControl';
export const shouldRenderInviteUserButton =
export const shouldRenderInviteUserButton = () =>
config.featureToggles.inviteUserExperimental &&
config.externalUserMngLinkUrl &&
contextSrv.hasPermission(AccessControlAction.OrgUsersAdd);
@@ -107,7 +107,7 @@ export const SingleTopBar = memo(function SingleTopBar({
{config.featureToggles.extensionSidebar && !isSmallScreen && <ExtensionToolbarItem />}
{!showToolbarLevel && actions}
{!contextSrv.user.isSignedIn && <SignInLink />}
{config.featureToggles.inviteUserExperimental && <InviteUserButton />}
<InviteUserButton />
{profileNode && <ProfileButton profileNode={profileNode} onToggleKioskMode={onToggleKioskMode} />}
</Stack>
</div>
@@ -1,21 +0,0 @@
import { t } from '@grafana/i18n';
import { Button } from '@grafana/ui';
import { performInviteUserClick } from './utils';
export function InviteUserButton() {
return (
<Button
icon="add-user"
size="sm"
variant="secondary"
fill="solid"
fullWidth
onClick={() => {
performInviteUserClick('bottom_mega_menu', 'invite-user-bottom-mega-menu');
}}
>
{t('navigation.invite-user.invite-new-member-button', 'Invite new member')}
</Button>
);
}
@@ -3,7 +3,10 @@ import { useMemo } from 'react';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { enrichHelpItem } from 'app/core/components/AppChrome/MegaMenu/utils';
import { performInviteUserClick, shouldRenderInviteUserButton } from 'app/core/components/InviteUserButton/utils';
import {
shouldRenderInviteUserButton,
performInviteUserClick,
} from 'app/core/components/AppChrome/TopBar/InviteUserButtonUtils';
import { changeTheme } from 'app/core/services/theme';
import { currentMockApiState, toggleMockApiAndReload, togglePseudoLocale } from 'app/dev-utils';
import { useSelector } from 'app/types/store';
@@ -139,10 +142,10 @@ export function useStaticActions(): CommandPaletteAction[] {
return useMemo(() => {
const navBarActions = navTreeToActions(navBarTree);
if (shouldRenderInviteUserButton) {
if (shouldRenderInviteUserButton()) {
navBarActions.push({
id: 'invite-user',
name: t('navigation.invite-user.invite-new-member-button', 'Invite new member'),
name: t('navigation.invite-user.invite-new-user-button', 'Invite new user'),
section: t('command-palette.section.actions', 'Actions'),
priority: ACTIONS_PRIORITY,
perform: () => {
+1 -1
View File
@@ -10315,7 +10315,7 @@
},
"invite-user": {
"invite-button": "Invite",
"invite-new-member-button": "Invite new member",
"invite-new-user-button": "Invite new user",
"invite-tooltip": "Invite user"
},
"item": {