Compare commits

...

7 Commits

5 changed files with 208 additions and 14 deletions

View File

@@ -1,9 +1,10 @@
import { skipToken } from '@reduxjs/toolkit/query';
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/services/context_srv';
import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
import { getExternalUserMngLinkUrl, getUpgradeUrl } from 'app/features/users/utils';
import { InviteUserButton } from './InviteUserButton';
@@ -12,6 +13,12 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
externalUserMngLinkUrl: 'https://example.com/invite',
namespace: 'default', // on-prem by default
bootData: {
user: {
orgName: 'test-org',
},
},
},
reportInteraction: jest.fn(),
}));
@@ -24,12 +31,18 @@ jest.mock('app/core/services/context_srv', () => ({
jest.mock('app/features/users/utils', () => ({
getExternalUserMngLinkUrl: jest.fn(),
getUpgradeUrl: jest.fn(),
}));
jest.mock('app/api/clients/legacy', () => ({
useGetCurrentOrgQuotaQuery: jest.fn(),
}));
const mockContextSrv = jest.mocked(contextSrv);
const mockConfig = jest.mocked(config);
const mockReportInteraction = jest.mocked(reportInteraction);
const mockGetExternalUserMngLinkUrl = jest.mocked(getExternalUserMngLinkUrl);
const mockGetUpgradeUrl = jest.mocked(getUpgradeUrl);
// Mock window.open
const mockWindowOpen = jest.fn();
@@ -52,10 +65,27 @@ const mockMatchMedia = (matches: boolean) => {
describe('InviteUserButton', () => {
const mockInviteUrl = 'https://example.com/invite?cnt=invite-user-top-bar';
const mockUpgradeUrl = 'https://grafana.com/orgs/test-org/my-account/manage-plan?cnt=upgrade-user-top-bar';
// Import the mocked hook
const { useGetCurrentOrgQuotaQuery } = require('app/api/clients/legacy');
const mockUseGetCurrentOrgQuotaQuery = jest.mocked(useGetCurrentOrgQuotaQuery);
beforeEach(() => {
jest.clearAllMocks();
mockGetExternalUserMngLinkUrl.mockReturnValue(mockInviteUrl);
mockGetUpgradeUrl.mockReturnValue(mockUpgradeUrl);
// Default mock: no quotas, no error (on-prem scenario)
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
data: undefined,
error: undefined,
isLoading: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
// Default to on-prem
mockConfig.namespace = 'default';
});
describe('Business Logic - When button should appear', () => {
@@ -128,11 +158,109 @@ describe('InviteUserButton', () => {
});
});
describe('Upgrade functionality - Grafana Cloud', () => {
beforeEach(() => {
mockConfig.externalUserMngLinkUrl = 'https://example.com/invite';
mockContextSrv.hasPermission.mockReturnValue(true);
mockMatchMedia(true);
// Simulate Grafana Cloud
mockConfig.namespace = 'stacks-12345';
});
it('should show invite button when quota is not reached', () => {
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
data: [{ target: 'org_user', limit: 5, used: 2, org_id: 1 }],
error: undefined,
isLoading: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
render(<InviteUserButton />);
const button = screen.getByRole('button', { name: /invite user/i });
expect(button).toHaveTextContent('Invite');
});
it('should show upgrade button when quota is reached', () => {
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
data: [{ target: 'org_user', limit: 5, used: 5, org_id: 1 }],
error: undefined,
isLoading: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
render(<InviteUserButton />);
const button = screen.getByRole('button', { name: /upgrade to invite more users/i });
expect(button).toHaveTextContent('Upgrade');
});
it('should open upgrade URL when upgrade button is clicked', async () => {
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
data: [{ target: 'org_user', limit: 5, used: 5, org_id: 1 }],
error: undefined,
isLoading: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const user = userEvent.setup();
render(<InviteUserButton />);
await user.click(screen.getByRole('button', { name: /upgrade to invite more users/i }));
expect(mockReportInteraction).toHaveBeenCalledWith('upgrade_user_button_clicked', {
placement: 'top_bar_right',
});
expect(mockGetUpgradeUrl).toHaveBeenCalledWith('upgrade-user-top-bar');
expect(mockWindowOpen).toHaveBeenCalledWith(mockUpgradeUrl, '_blank');
});
it('should not fetch quotas on on-prem instances', () => {
mockConfig.namespace = 'default'; // on-prem
render(<InviteUserButton />);
// Should skip the query with skipToken
expect(mockUseGetCurrentOrgQuotaQuery).toHaveBeenCalledWith(skipToken);
});
it('should fetch quotas on cloud instances when button will render', () => {
mockConfig.namespace = 'stacks-12345'; // cloud
render(<InviteUserButton />);
// Should not skip the query (pass undefined)
expect(mockUseGetCurrentOrgQuotaQuery).toHaveBeenCalledWith(undefined);
});
});
describe('Error Handling - Preventing crashes', () => {
beforeEach(() => {
mockConfig.externalUserMngLinkUrl = 'https://example.com/invite';
mockContextSrv.hasPermission.mockReturnValue(true);
mockMatchMedia(true);
mockConfig.namespace = 'default'; // on-prem
});
it('should handle quota API errors gracefully', () => {
mockConfig.namespace = 'stacks-12345';
mockUseGetCurrentOrgQuotaQuery.mockReturnValue({
data: undefined,
error: { message: 'API Error' },
isLoading: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<InviteUserButton />);
// Should still render the invite button (no quota check)
expect(screen.getByRole('button', { name: /invite user/i })).toBeInTheDocument();
expect(consoleSpy).toHaveBeenCalledWith('Failed to fetch org quotas:', { message: 'API Error' });
consoleSpy.mockRestore();
});
it('should handle URL generation errors gracefully', async () => {
@@ -148,7 +276,7 @@ describe('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));
expect(consoleSpy).toHaveBeenCalledWith('Failed to handle button click:', expect.any(Error));
consoleSpy.mockRestore();
});
@@ -166,7 +294,7 @@ describe('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));
expect(consoleSpy).toHaveBeenCalledWith('Failed to handle button click:', expect.any(Error));
consoleSpy.mockRestore();
});

View File

@@ -1,33 +1,66 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { t } from '@grafana/i18n';
import { ToolbarButton } from '@grafana/ui';
import { useGetCurrentOrgQuotaQuery } from 'app/api/clients/legacy';
import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth';
import { isOnPrem } from 'app/features/provisioning/utils/isOnPrem';
import { NavToolbarSeparator } from '../NavToolbar/NavToolbarSeparator';
import { performInviteUserClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
import { performInviteUserClick, performUpgradeClick, shouldRenderInviteUserButton } from './InviteUserButtonUtils';
export function InviteUserButton() {
const isLargeScreen = useMediaQueryMinWidth('lg');
const shouldRender = shouldRenderInviteUserButton();
const isCloudInstance = !isOnPrem();
// Only fetch quotas when button will render AND on Grafana Cloud
const { data: quotas, error } = useGetCurrentOrgQuotaQuery(!shouldRender || !isCloudInstance ? skipToken : undefined);
// Check if org_user quota is reached
const userQuota = quotas?.find((quota) => quota.target === 'org_user');
const isQuotaReached =
userQuota != null && userQuota.used != null && userQuota.limit != null && userQuota.used >= userQuota.limit;
// Only show upgrade button on Grafana Cloud when quota is reached
const shouldShowUpgrade = isCloudInstance && isQuotaReached;
if (error) {
console.error('Failed to fetch org quotas:', error);
}
const handleClick = () => {
try {
if (shouldShowUpgrade) {
performUpgradeClick('top_bar_right', 'upgrade-user-top-bar');
} else {
performInviteUserClick('top_bar_right', 'invite-user-top-bar');
}
} catch (error) {
console.error('Failed to handle invite user click:', error);
console.error('Failed to handle button click:', error);
}
};
const buttonLabel = shouldShowUpgrade
? t('navigation.invite-user.upgrade-tooltip', 'Upgrade to invite more users')
: t('navigation.invite-user.invite-tooltip', 'Invite user');
return (
shouldRenderInviteUserButton() && (
shouldRender && (
<>
<ToolbarButton
icon="add-user"
icon={shouldShowUpgrade ? 'rocket' : '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')}
tooltip={buttonLabel}
aria-label={buttonLabel}
>
{isLargeScreen ? t('navigation.invite-user.invite-button', 'Invite') : undefined}
{isLargeScreen
? shouldShowUpgrade
? t('navigation.invite-user.upgrade-button', 'Upgrade')
: t('navigation.invite-user.invite-button', 'Invite')
: undefined}
</ToolbarButton>
<NavToolbarSeparator />
</>

View File

@@ -1,6 +1,6 @@
import { reportInteraction, config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { getExternalUserMngLinkUrl } from 'app/features/users/utils';
import { getExternalUserMngLinkUrl, getUpgradeUrl } from 'app/features/users/utils';
import { AccessControlAction } from 'app/types/accessControl';
export const shouldRenderInviteUserButton = () =>
@@ -12,5 +12,14 @@ export const performInviteUserClick = (placement: string, cnt: string) => {
});
const url = getExternalUserMngLinkUrl(cnt);
window.open(url.toString(), '_blank');
window.open(url, '_blank');
};
export const performUpgradeClick = (placement: string, cnt: string) => {
reportInteraction('upgrade_user_button_clicked', {
placement,
});
const url = getUpgradeUrl(cnt);
window.open(url, '_blank');
};

View File

@@ -19,3 +19,25 @@ export function getExternalUserMngLinkUrl(cnt: string) {
return url.toString();
}
export function getUpgradeUrl(cnt?: string) {
const orgName = config.bootData?.user?.orgName;
let baseUrl: string;
if (orgName) {
// Use org-specific URL: https://grafana.com/orgs/<org-name>/my-account/manage-plan
baseUrl = `https://grafana.com/orgs/${encodeURIComponent(orgName)}/my-account/manage-plan`;
} else {
// Fallback to generic subscription page
baseUrl = 'https://grafana.com/profile/org/subscription';
}
// Add cnt parameter for conversion tracking if provided
if (cnt) {
const url = new URL(baseUrl);
url.searchParams.append('cnt', cnt);
return url.toString();
}
return baseUrl;
}

View File

@@ -10729,7 +10729,9 @@
"invite-user": {
"invite-button": "Invite",
"invite-new-user-button": "Invite new user",
"invite-tooltip": "Invite user"
"invite-tooltip": "Invite user",
"upgrade-button": "Upgrade",
"upgrade-tooltip": "Upgrade to invite more users"
},
"item": {
"add-bookmark": "Add to Bookmarks",