From 865e911e104ef8619102f27c3eb5ecef7f6dde0b Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Tue, 21 Jan 2025 11:47:57 -0300 Subject: [PATCH] Share: Add analytics to invite user flow (#99116) --- packages/grafana-data/src/types/config.ts | 2 ++ packages/grafana-runtime/src/config.ts | 2 ++ pkg/api/dtos/frontend_settings.go | 2 ++ pkg/api/frontendsettings.go | 2 ++ pkg/setting/setting.go | 32 +++++++++-------- .../sharing/ShareButton/ShareMenu.test.tsx | 28 +++++++++++++++ .../sharing/ShareButton/ShareMenu.tsx | 5 ++- .../features/users/UsersActionBar.test.tsx | 34 +++++++++++++++++-- public/app/features/users/UsersActionBar.tsx | 3 +- public/app/features/users/utils.ts | 21 ++++++++++++ 10 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 public/app/features/users/utils.ts diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 50b9461ba19bd..b1f6ca4b28a6e 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -170,6 +170,8 @@ export interface GrafanaConfig { externalUserMngLinkUrl: string; externalUserMngLinkName: string; externalUserMngInfo: string; + externalUserMngAnalytics: boolean; + externalUserMngAnalyticsParams: string; allowOrgCreate: boolean; disableLoginForm: boolean; defaultDatasource: string; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index 0ebfab4e4fd46..f8b087b1bc6b0 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -73,6 +73,8 @@ export class GrafanaBootConfig implements GrafanaConfig { externalUserMngLinkUrl = ''; externalUserMngLinkName = ''; externalUserMngInfo = ''; + externalUserMngAnalytics = false; + externalUserMngAnalyticsParams = ''; allowOrgCreate = false; feedbackLinksEnabled = true; disableLoginForm = false; diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index e33490bd41b72..d7094de9d902c 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -200,6 +200,8 @@ type FrontendSettingsDTO struct { ExternalUserMngInfo string `json:"externalUserMngInfo"` ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` ExternalUserMngLinkName string `json:"externalUserMngLinkName"` + ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"` + ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"` ViewersCanEdit bool `json:"viewersCanEdit"` AngularSupportEnabled bool `json:"angularSupportEnabled"` EditorsCanAdmin bool `json:"editorsCanAdmin"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 8f17ad1e85e1a..f11114b299aa6 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -225,6 +225,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo, ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl, ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName, + ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics, + ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams, ViewersCanEdit: hs.Cfg.ViewersCanEdit, AngularSupportEnabled: hs.Cfg.AngularSupportEnabled, EditorsCanAdmin: hs.Cfg.EditorsCanAdmin, diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 7eca72b15f4bd..934fa3152a198 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -397,20 +397,22 @@ type Cfg struct { Quota QuotaSettings // User settings - AllowUserSignUp bool - AllowUserOrgCreate bool - VerifyEmailEnabled bool - LoginHint string - PasswordHint string - DisableSignoutMenu bool - ExternalUserMngLinkUrl string - ExternalUserMngLinkName string - ExternalUserMngInfo string - AutoAssignOrg bool - AutoAssignOrgId int - AutoAssignOrgRole string - LoginDefaultOrgId int64 - OAuthSkipOrgRoleUpdateSync bool + AllowUserSignUp bool + AllowUserOrgCreate bool + VerifyEmailEnabled bool + LoginHint string + PasswordHint string + DisableSignoutMenu bool + ExternalUserMngLinkUrl string + ExternalUserMngLinkName string + ExternalUserMngInfo string + ExternalUserMngAnalytics bool + ExternalUserMngAnalyticsParams string + AutoAssignOrg bool + AutoAssignOrgId int + AutoAssignOrgRole string + LoginDefaultOrgId int64 + OAuthSkipOrgRoleUpdateSync bool // ExpressionsEnabled specifies whether expressions are enabled. ExpressionsEnabled bool @@ -1713,6 +1715,8 @@ func readUserSettings(iniFile *ini.File, cfg *Cfg) error { cfg.ExternalUserMngLinkUrl = valueAsString(users, "external_manage_link_url", "") cfg.ExternalUserMngLinkName = valueAsString(users, "external_manage_link_name", "") 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.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false) diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx index 5d865ce2b4c48..403e76d2c7f04 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.test.tsx @@ -59,6 +59,34 @@ describe('ShareMenu', () => { 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 () => { Object.defineProperty(contextSrv, 'isSignedIn', { value: true, diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx index c701e2a41ec9d..fb97d7cd8e803 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx @@ -13,6 +13,7 @@ import { AccessControlAction } from 'app/types'; import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; import { getTrackingSource, shareDashboardType } from '../../../dashboard/components/ShareModal/utils'; +import { getExternalUserMngLinkUrl } from '../../../users/utils'; import { DashboardScene } from '../../scene/DashboardScene'; 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'), renderCondition: !!config.externalUserMngLinkUrl && contextSrv.hasPermission(AccessControlAction.OrgUsersAdd), onClick: () => { - window.open(config.externalUserMngLinkUrl, '_blank'); + const url = getExternalUserMngLinkUrl('share-invite'); + + window.open(url.toString(), '_blank'); }, renderDividerAbove: true, component: () => , diff --git a/public/app/features/users/UsersActionBar.test.tsx b/public/app/features/users/UsersActionBar.test.tsx index 61516544a16b5..9e5e46f1494bf 100644 --- a/public/app/features/users/UsersActionBar.test.tsx +++ b/public/app/features/users/UsersActionBar.test.tsx @@ -25,6 +25,9 @@ const setup = (propOverrides?: object) => { Object.assign(props, propOverrides); + config.externalUserMngLinkUrl = props.externalUserMngLinkUrl; + config.externalUserMngLinkName = props.externalUserMngLinkName; + const { rerender } = render(); return { rerender, props }; @@ -53,11 +56,38 @@ describe('Render', () => { it('should show external user management button', () => { setup({ - externalUserMngLinkUrl: 'some/url', + externalUserMngLinkUrl: 'http://some/url', + externalUserMngLinkName: 'someUrl', + }); + + 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', 'some/url'); + 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', () => { diff --git a/public/app/features/users/UsersActionBar.tsx b/public/app/features/users/UsersActionBar.tsx index ed09f86ed8806..48ef3a81e83de 100644 --- a/public/app/features/users/UsersActionBar.tsx +++ b/public/app/features/users/UsersActionBar.tsx @@ -9,6 +9,7 @@ import { selectTotal } from '../invites/state/selectors'; import { changeSearchQuery } from './state/actions'; import { getUsersSearchQuery } from './state/selectors'; +import { getExternalUserMngLinkUrl } from './utils'; export interface OwnProps { showInvites: boolean; @@ -67,7 +68,7 @@ export const UsersActionBarUnconnected = ({ )} {showInviteButton && Invite} {externalUserMngLinkUrl && ( - + {externalUserMngLinkName} )} diff --git a/public/app/features/users/utils.ts b/public/app/features/users/utils.ts new file mode 100644 index 0000000000000..def3777d47683 --- /dev/null +++ b/public/app/features/users/utils.ts @@ -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(); +}