mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 12:04:45 +08:00
Compare commits
7 Commits
zoltan/pos
...
erhilse/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd08deb2c1 | ||
|
|
a3b6f0e757 | ||
|
|
922ddd080b | ||
|
|
c80726eaf6 | ||
|
|
87484c8168 | ||
|
|
d00cfb35c5 | ||
|
|
1ebd8ee806 |
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user