Share: Add analytics to invite user flow (#99116)

This commit is contained in:
Ezequiel Victorero 2025-01-21 11:47:57 -03:00 committed by GitHub
parent c7edbffd82
commit 865e911e10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 113 additions and 18 deletions

View File

@ -170,6 +170,8 @@ export interface GrafanaConfig {
externalUserMngLinkUrl: string; externalUserMngLinkUrl: string;
externalUserMngLinkName: string; externalUserMngLinkName: string;
externalUserMngInfo: string; externalUserMngInfo: string;
externalUserMngAnalytics: boolean;
externalUserMngAnalyticsParams: string;
allowOrgCreate: boolean; allowOrgCreate: boolean;
disableLoginForm: boolean; disableLoginForm: boolean;
defaultDatasource: string; defaultDatasource: string;

View File

@ -73,6 +73,8 @@ export class GrafanaBootConfig implements GrafanaConfig {
externalUserMngLinkUrl = ''; externalUserMngLinkUrl = '';
externalUserMngLinkName = ''; externalUserMngLinkName = '';
externalUserMngInfo = ''; externalUserMngInfo = '';
externalUserMngAnalytics = false;
externalUserMngAnalyticsParams = '';
allowOrgCreate = false; allowOrgCreate = false;
feedbackLinksEnabled = true; feedbackLinksEnabled = true;
disableLoginForm = false; disableLoginForm = false;

View File

@ -200,6 +200,8 @@ type FrontendSettingsDTO struct {
ExternalUserMngInfo string `json:"externalUserMngInfo"` ExternalUserMngInfo string `json:"externalUserMngInfo"`
ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"`
ExternalUserMngLinkName string `json:"externalUserMngLinkName"` ExternalUserMngLinkName string `json:"externalUserMngLinkName"`
ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"`
ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"`
ViewersCanEdit bool `json:"viewersCanEdit"` ViewersCanEdit bool `json:"viewersCanEdit"`
AngularSupportEnabled bool `json:"angularSupportEnabled"` AngularSupportEnabled bool `json:"angularSupportEnabled"`
EditorsCanAdmin bool `json:"editorsCanAdmin"` EditorsCanAdmin bool `json:"editorsCanAdmin"`

View File

@ -225,6 +225,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo, ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo,
ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl, ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl,
ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName, ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName,
ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics,
ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams,
ViewersCanEdit: hs.Cfg.ViewersCanEdit, ViewersCanEdit: hs.Cfg.ViewersCanEdit,
AngularSupportEnabled: hs.Cfg.AngularSupportEnabled, AngularSupportEnabled: hs.Cfg.AngularSupportEnabled,
EditorsCanAdmin: hs.Cfg.EditorsCanAdmin, EditorsCanAdmin: hs.Cfg.EditorsCanAdmin,

View File

@ -397,20 +397,22 @@ type Cfg struct {
Quota QuotaSettings Quota QuotaSettings
// User settings // User settings
AllowUserSignUp bool AllowUserSignUp bool
AllowUserOrgCreate bool AllowUserOrgCreate bool
VerifyEmailEnabled bool VerifyEmailEnabled bool
LoginHint string LoginHint string
PasswordHint string PasswordHint string
DisableSignoutMenu bool DisableSignoutMenu bool
ExternalUserMngLinkUrl string ExternalUserMngLinkUrl string
ExternalUserMngLinkName string ExternalUserMngLinkName string
ExternalUserMngInfo string ExternalUserMngInfo string
AutoAssignOrg bool ExternalUserMngAnalytics bool
AutoAssignOrgId int ExternalUserMngAnalyticsParams string
AutoAssignOrgRole string AutoAssignOrg bool
LoginDefaultOrgId int64 AutoAssignOrgId int
OAuthSkipOrgRoleUpdateSync bool AutoAssignOrgRole string
LoginDefaultOrgId int64
OAuthSkipOrgRoleUpdateSync bool
// ExpressionsEnabled specifies whether expressions are enabled. // ExpressionsEnabled specifies whether expressions are enabled.
ExpressionsEnabled bool ExpressionsEnabled bool
@ -1713,6 +1715,8 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error {
cfg.ExternalUserMngLinkUrl = valueAsString(users, "external_manage_link_url", "") cfg.ExternalUserMngLinkUrl = valueAsString(users, "external_manage_link_url", "")
cfg.ExternalUserMngLinkName = valueAsString(users, "external_manage_link_name", "") cfg.ExternalUserMngLinkName = valueAsString(users, "external_manage_link_name", "")
cfg.ExternalUserMngInfo = valueAsString(users, "external_manage_info", "") cfg.ExternalUserMngInfo = valueAsString(users, "external_manage_info", "")
cfg.ExternalUserMngAnalytics = users.Key("external_manage_analytics").MustBool(false)
cfg.ExternalUserMngAnalyticsParams = valueAsString(users, "external_manage_analytics_params", "")
cfg.ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false) cfg.ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false)
cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false) cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false)

View File

@ -59,6 +59,34 @@ describe('ShareMenu', () => {
expect(await screen.queryByTestId(selector.inviteUser)).not.toBeInTheDocument(); expect(await screen.queryByTestId(selector.inviteUser)).not.toBeInTheDocument();
}); });
it('should render invite user with analytics when config is provided', async () => {
Object.defineProperty(contextSrv, 'isSignedIn', {
value: true,
});
grantUserPermissions([AccessControlAction.OrgUsersAdd]);
config.externalUserMngLinkUrl = 'http://localhost:3000/users';
config.externalUserMngAnalytics = true;
config.externalUserMngAnalyticsParams = 'src=grafananet&other=value1';
setup({ meta: { canEdit: true } });
const inviteUser = await screen.findByTestId(selector.inviteUser);
// Mock window.open
const windowOpenMock = jest.spyOn(window, 'open').mockImplementation(() => null);
// Simulate click event
inviteUser.click();
// Assert window.open was called with the correct URL
expect(windowOpenMock).toHaveBeenCalledWith(
'http://localhost:3000/users?src=grafananet&other=value1&cnt=share-invite',
'_blank'
);
// Restore the original implementation
windowOpenMock.mockRestore();
});
it('should not render invite user when externalUserMngLinkUrl is not provided', async () => { it('should not render invite user when externalUserMngLinkUrl is not provided', async () => {
Object.defineProperty(contextSrv, 'isSignedIn', { Object.defineProperty(contextSrv, 'isSignedIn', {
value: true, value: true,

View File

@ -13,6 +13,7 @@ import { AccessControlAction } from 'app/types';
import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
import { getTrackingSource, shareDashboardType } from '../../../dashboard/components/ShareModal/utils'; import { getTrackingSource, shareDashboardType } from '../../../dashboard/components/ShareModal/utils';
import { getExternalUserMngLinkUrl } from '../../../users/utils';
import { DashboardScene } from '../../scene/DashboardScene'; import { DashboardScene } from '../../scene/DashboardScene';
import { DashboardInteractions } from '../../utils/interactions'; import { DashboardInteractions } from '../../utils/interactions';
@ -93,7 +94,9 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
label: t('share-dashboard.menu.invite-user-title', 'Invite new member'), label: t('share-dashboard.menu.invite-user-title', 'Invite new member'),
renderCondition: !!config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd), renderCondition: !!config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd),
onClick: () => { onClick: () => {
window.open(config.externalUserMngLinkUrl, '_blank'); const url = getExternalUserMngLinkUrl('share-invite');
window.open(url.toString(), '_blank');
}, },
renderDividerAbove: true, renderDividerAbove: true,
component: () => <Icon name="external-link-alt" className={styles.inviteUserItemIcon} />, component: () => <Icon name="external-link-alt" className={styles.inviteUserItemIcon} />,

View File

@ -25,6 +25,9 @@ const setup = (propOverrides?: object) => {
Object.assign(props, propOverrides); Object.assign(props, propOverrides);
config.externalUserMngLinkUrl = props.externalUserMngLinkUrl;
config.externalUserMngLinkName = props.externalUserMngLinkName;
const { rerender } = render(<UsersActionBarUnconnected {...props} />); const { rerender } = render(<UsersActionBarUnconnected {...props} />);
return { rerender, props }; return { rerender, props };
@ -53,11 +56,38 @@ describe('Render', () => {
it('should show external user management button', () => { it('should show external user management button', () => {
setup({ setup({
externalUserMngLinkUrl: 'some/url', externalUserMngLinkUrl: 'http://some/url',
externalUserMngLinkName: 'someUrl', externalUserMngLinkName: 'someUrl',
}); });
expect(screen.getByRole('link', { name: 'someUrl' })).toHaveAttribute('href', 'some/url'); expect(screen.getByRole('link', { name: 'someUrl' })).toHaveAttribute('href', 'http://some/url');
});
it('should show external user management button with analytics values when configured', () => {
config.externalUserMngAnalytics = true;
config.externalUserMngAnalyticsParams = 'src=grafananet&other=value1';
setup({
externalUserMngLinkUrl: 'http://some/url',
externalUserMngLinkName: 'someUrl',
});
expect(screen.getByRole('link', { name: 'someUrl' })).toHaveAttribute(
'href',
'http://some/url?src=grafananet&other=value1&cnt=manage-users'
);
});
it('should show external user management button without analytics values when disabled', () => {
config.externalUserMngAnalytics = false;
config.externalUserMngAnalyticsParams = 'src=grafananet&other=value1';
setup({
externalUserMngLinkUrl: 'http://some/url',
externalUserMngLinkName: 'someUrl',
});
expect(screen.getByRole('link', { name: 'someUrl' })).toHaveAttribute('href', 'http://some/url');
}); });
it('should not show invite button when externalUserMngInfo is set and disableLoginForm is true', () => { it('should not show invite button when externalUserMngInfo is set and disableLoginForm is true', () => {

View File

@ -9,6 +9,7 @@ import { selectTotal } from '../invites/state/selectors';
import { changeSearchQuery } from './state/actions'; import { changeSearchQuery } from './state/actions';
import { getUsersSearchQuery } from './state/selectors'; import { getUsersSearchQuery } from './state/selectors';
import { getExternalUserMngLinkUrl } from './utils';
export interface OwnProps { export interface OwnProps {
showInvites: boolean; showInvites: boolean;
@ -67,7 +68,7 @@ export const UsersActionBarUnconnected = ({
)} )}
{showInviteButton && <LinkButton href="org/users/invite">Invite</LinkButton>} {showInviteButton && <LinkButton href="org/users/invite">Invite</LinkButton>}
{externalUserMngLinkUrl && ( {externalUserMngLinkUrl && (
<LinkButton href={externalUserMngLinkUrl} target="_blank" rel="noopener"> <LinkButton href={getExternalUserMngLinkUrl('manage-users')} target="_blank" rel="noopener">
{externalUserMngLinkName} {externalUserMngLinkName}
</LinkButton> </LinkButton>
)} )}

View File

@ -0,0 +1,21 @@
import { config } from '@grafana/runtime';
export function getExternalUserMngLinkUrl(cnt: string) {
const url = new URL(config.externalUserMngLinkUrl);
if (config.externalUserMngAnalytics) {
// Add query parameters in config.externalUserMngAnalyticsParams to track conversion
if (!!config.externalUserMngAnalyticsParams) {
const params = config.externalUserMngAnalyticsParams.split('&');
params.forEach((param) => {
const [key, value] = param.split('=');
url.searchParams.append(key, value);
});
}
// Add specific CTA cnt to track conversion
url.searchParams.append('cnt', cnt);
}
return url.toString();
}