From 5a5f33146ac2e224b13c0dce33cf162c5ae70a6f Mon Sep 17 00:00:00 2001 From: Siegfried Ehret <49895321+siegfried-ehret-sonarsource@users.noreply.github.com> Date: Mon, 13 May 2019 16:25:18 +0200 Subject: [PATCH] SONARCLOUD-615 Add Google Tag Manager --- .../org/sonar/process/ProcessProperties.java | 3 +- .../org/sonar/server/ui/ws/GlobalAction.java | 6 +- .../sonar/server/ui/ws/GlobalActionTest.java | 2 + .../src/main/js/app/components/App.tsx | 8 +- .../main/js/app/components/PageTracker.tsx | 75 +++++++++++++----- .../components/SimpleSessionsContainer.tsx | 17 ++-- .../components/__tests__/PageTracker-test.tsx | 78 +++++++++++++++++++ server/sonar-web/src/main/js/app/index.ts | 13 +++- .../src/main/js/helpers/analytics.js | 27 +++++++ 9 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 server/sonar-web/src/main/js/app/components/__tests__/PageTracker-test.tsx create mode 100644 server/sonar-web/src/main/js/helpers/analytics.js diff --git a/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java b/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java index 03c8655eb9b..cf9dce23bd5 100644 --- a/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java +++ b/server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java @@ -110,7 +110,8 @@ public class ProcessProperties { SONARCLOUD_ENABLED("sonar.sonarcloud.enabled", "false"), SONARCLOUD_HOMEPAGE_URL("sonar.homepage.url", ""), SONAR_PRISMIC_ACCESS_TOKEN("sonar.prismic.accessToken", ""), - SONAR_ANALYTICS_TRACKING_ID("sonar.analytics.ga.trackingId", ""), + SONAR_ANALYTICS_GA_TRACKING_ID("sonar.analytics.ga.trackingId", ""), + SONAR_ANALYTICS_GTM_TRACKING_ID("sonar.analytics.gtm.trackingId", ""), ONBOARDING_TUTORIAL_SHOW_TO_NEW_USERS("sonar.onboardingTutorial.showToNewUsers", "true"), BITBUCKETCLOUD_APP_KEY("sonar.bitbucketcloud.appKey", "sonarcloud"), diff --git a/server/sonar-server/src/main/java/org/sonar/server/ui/ws/GlobalAction.java b/server/sonar-server/src/main/java/org/sonar/server/ui/ws/GlobalAction.java index 2192601ac5e..de3f26f9f83 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/ui/ws/GlobalAction.java +++ b/server/sonar-server/src/main/java/org/sonar/server/ui/ws/GlobalAction.java @@ -53,7 +53,8 @@ import static org.sonar.core.config.WebConstants.SONAR_LF_LOGO_URL; import static org.sonar.core.config.WebConstants.SONAR_LF_LOGO_WIDTH_PX; import static org.sonar.process.ProcessProperties.Property.SONARCLOUD_ENABLED; import static org.sonar.process.ProcessProperties.Property.SONARCLOUD_HOMEPAGE_URL; -import static org.sonar.process.ProcessProperties.Property.SONAR_ANALYTICS_TRACKING_ID; +import static org.sonar.process.ProcessProperties.Property.SONAR_ANALYTICS_GTM_TRACKING_ID; +import static org.sonar.process.ProcessProperties.Property.SONAR_ANALYTICS_GA_TRACKING_ID; import static org.sonar.process.ProcessProperties.Property.SONAR_PRISMIC_ACCESS_TOKEN; import static org.sonar.process.ProcessProperties.Property.SONAR_UPDATECENTER_ACTIVATE; @@ -103,7 +104,8 @@ public class GlobalAction implements NavigationWsAction, Startable { boolean isOnSonarCloud = config.getBoolean(SONARCLOUD_ENABLED.getKey()).orElse(false); if (isOnSonarCloud) { this.systemSettingValuesByKey.put(SONAR_PRISMIC_ACCESS_TOKEN.getKey(), config.get(SONAR_PRISMIC_ACCESS_TOKEN.getKey()).orElse(null)); - this.systemSettingValuesByKey.put(SONAR_ANALYTICS_TRACKING_ID.getKey(), config.get(SONAR_ANALYTICS_TRACKING_ID.getKey()).orElse(null)); + this.systemSettingValuesByKey.put(SONAR_ANALYTICS_GA_TRACKING_ID.getKey(), config.get(SONAR_ANALYTICS_GA_TRACKING_ID.getKey()).orElse(null)); + this.systemSettingValuesByKey.put(SONAR_ANALYTICS_GTM_TRACKING_ID.getKey(), config.get(SONAR_ANALYTICS_GTM_TRACKING_ID.getKey()).orElse(null)); this.systemSettingValuesByKey.put(SONARCLOUD_HOMEPAGE_URL.getKey(), config.get(SONARCLOUD_HOMEPAGE_URL.getKey()).orElse(null)); } } diff --git a/server/sonar-server/src/test/java/org/sonar/server/ui/ws/GlobalActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/ui/ws/GlobalActionTest.java index 02291004932..19b40373e8b 100644 --- a/server/sonar-server/src/test/java/org/sonar/server/ui/ws/GlobalActionTest.java +++ b/server/sonar-server/src/test/java/org/sonar/server/ui/ws/GlobalActionTest.java @@ -131,6 +131,7 @@ public class GlobalActionTest { settings.setProperty("sonar.sonarcloud.enabled", true); settings.setProperty("sonar.prismic.accessToken", "secret"); settings.setProperty("sonar.analytics.ga.trackingId", "ga_id"); + settings.setProperty("sonar.analytics.gtm.trackingId", "gtm_id"); settings.setProperty("sonar.homepage.url", "https://s3/homepage.json"); init(); @@ -138,6 +139,7 @@ public class GlobalActionTest { " \"settings\": {" + " \"sonar.prismic.accessToken\": \"secret\"," + " \"sonar.analytics.ga.trackingId\": \"ga_id\"," + + " \"sonar.analytics.gtm.trackingId\": \"gtm_id\"," + " \"sonar.homepage.url\": \"https://s3/homepage.json\"" + " }" + "}"); diff --git a/server/sonar-web/src/main/js/app/components/App.tsx b/server/sonar-web/src/main/js/app/components/App.tsx index 1d9378e9c74..82f566cbcee 100644 --- a/server/sonar-web/src/main/js/app/components/App.tsx +++ b/server/sonar-web/src/main/js/app/components/App.tsx @@ -19,10 +19,9 @@ */ import * as React from 'react'; import { connect } from 'react-redux'; -import Helmet from 'react-helmet'; import { fetchLanguages } from '../../store/rootActions'; import { fetchMyOrganizations } from '../../apps/account/organizations/actions'; -import { getInstance, isSonarCloud } from '../../helpers/system'; +import { isSonarCloud } from '../../helpers/system'; import { lazyLoad } from '../../components/lazyLoad'; import { getCurrentUser, getAppState, getGlobalSettingValue, Store } from '../../store/rootReducer'; import { isLoggedIn } from '../../helpers/users'; @@ -102,10 +101,7 @@ class App extends React.PureComponent { render() { return ( <> - - {this.props.enableGravatar && this.renderPreconnectLink()} - - + {this.props.enableGravatar && this.renderPreconnectLink()} {this.props.children} ); diff --git a/server/sonar-web/src/main/js/app/components/PageTracker.tsx b/server/sonar-web/src/main/js/app/components/PageTracker.tsx index 9c8825be56f..322f2eb7c75 100644 --- a/server/sonar-web/src/main/js/app/components/PageTracker.tsx +++ b/server/sonar-web/src/main/js/app/components/PageTracker.tsx @@ -21,48 +21,85 @@ import * as React from 'react'; import * as GoogleAnalytics from 'react-ga'; import { withRouter, WithRouterProps } from 'react-router'; import { connect } from 'react-redux'; +import Helmet from 'react-helmet'; import { getGlobalSettingValue, Store } from '../../store/rootReducer'; +import { gtm } from '../../helpers/analytics'; +import { getInstance } from '../../helpers/system'; interface StateProps { - trackingId?: string; + trackingIdGA?: string; + trackingIdGTM?: string; } type Props = WithRouterProps & StateProps; -export class PageTracker extends React.PureComponent { +interface State { + lastLocation?: string; +} + +export class PageTracker extends React.Component { + state: State = { + lastLocation: undefined + }; + componentDidMount() { - if (this.props.trackingId) { - GoogleAnalytics.initialize(this.props.trackingId); - this.trackPage(); - } - } + const { trackingIdGA, trackingIdGTM } = this.props; - componentDidUpdate(prevProps: Props) { - const currentPage = this.props.location.pathname; - const prevPage = prevProps.location.pathname; + if (trackingIdGA) { + GoogleAnalytics.initialize(trackingIdGA); + } - if (currentPage !== prevPage) { - this.trackPage(); + if (trackingIdGTM) { + gtm(trackingIdGTM); } } trackPage = () => { - const { location, trackingId } = this.props; - if (trackingId) { - // More info on the "title and page not in sync" issue: https://github.com/nfl/react-helmet/issues/189 - setTimeout(() => GoogleAnalytics.pageview(location.pathname), 500); + const { location, trackingIdGA, trackingIdGTM } = this.props; + const { lastLocation } = this.state; + + if (location.pathname !== lastLocation) { + if (trackingIdGA) { + // More info on the "title and page not in sync" issue: https://github.com/nfl/react-helmet/issues/189 + setTimeout(() => GoogleAnalytics.pageview(location.pathname), 500); + } + + if (trackingIdGTM && location.pathname !== '/') { + setTimeout(() => { + const { dataLayer } = window as any; + if (dataLayer && dataLayer.push) { + dataLayer.push({ event: 'render-end' }); + } + }, 500); + } + + this.setState({ + lastLocation: location.pathname + }); } }; render() { - return null; + const { trackingIdGA, trackingIdGTM } = this.props; + const tracking = { + ...((trackingIdGA || trackingIdGTM) && { onChangeClientState: this.trackPage }) + }; + + return ( + + {this.props.children} + + ); } } const mapStateToProps = (state: Store): StateProps => { - const trackingId = getGlobalSettingValue(state, 'sonar.analytics.ga.trackingId'); + const trackingIdGA = getGlobalSettingValue(state, 'sonar.analytics.ga.trackingId'); + const trackingIdGTM = getGlobalSettingValue(state, 'sonar.analytics.gtm.trackingId'); + return { - trackingId: trackingId && trackingId.value + trackingIdGA: trackingIdGA && trackingIdGA.value, + trackingIdGTM: trackingIdGTM && trackingIdGTM.value }; }; diff --git a/server/sonar-web/src/main/js/app/components/SimpleSessionsContainer.tsx b/server/sonar-web/src/main/js/app/components/SimpleSessionsContainer.tsx index 3a1a52548ed..32965ae187d 100644 --- a/server/sonar-web/src/main/js/app/components/SimpleSessionsContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/SimpleSessionsContainer.tsx @@ -19,6 +19,9 @@ */ import * as React from 'react'; import GlobalFooterContainer from './GlobalFooterContainer'; +import { lazyLoad } from '../../components/lazyLoad'; + +const PageTracker = lazyLoad(() => import('./PageTracker')); interface Props { children?: React.ReactNode; @@ -26,11 +29,15 @@ interface Props { export default function SimpleSessionsContainer({ children }: Props) { return ( -
-
- {children} + <> + + +
+
+ {children} +
+
- -
+ ); } diff --git a/server/sonar-web/src/main/js/app/components/__tests__/PageTracker-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/PageTracker-test.tsx new file mode 100644 index 00000000000..cd87a8f48ef --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/__tests__/PageTracker-test.tsx @@ -0,0 +1,78 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { mount } from 'enzyme'; +import * as React from 'react'; +import { PageTracker } from '../PageTracker'; +import { mockLocation, mockRouter } from '../../../helpers/testMocks'; + +jest.useFakeTimers(); + +beforeEach(() => { + jest.clearAllTimers(); + + (window as any).dataLayer = []; + + document.getElementsByTagName = jest.fn().mockImplementation(() => { + return [document.body]; + }); +}); + +it('should not trigger if no analytics system is given', () => { + shallowRender(); + + expect(setTimeout).not.toHaveBeenCalled(); +}); + +it('should work for Google Analytics', () => { + const wrapper = shallowRender({ trackingIdGA: '123' }); + const instance = wrapper.instance(); + instance.trackPage(); + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); +}); + +it('should work for Google Tag Manager', () => { + const wrapper = shallowRender({ trackingIdGTM: '123' }); + const instance = wrapper.instance(); + const dataLayer = (window as any).dataLayer; + + expect(dataLayer).toHaveLength(1); + + instance.trackPage(); + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); + + jest.runAllTimers(); + + expect(dataLayer).toHaveLength(2); +}); + +function shallowRender(props: Partial = {}) { + return mount( + + ); +} diff --git a/server/sonar-web/src/main/js/app/index.ts b/server/sonar-web/src/main/js/app/index.ts index defd6a0c1d1..60375997e7c 100644 --- a/server/sonar-web/src/main/js/app/index.ts +++ b/server/sonar-web/src/main/js/app/index.ts @@ -42,9 +42,16 @@ if (isMainApp()) { ); } else { // login, maintenance or setup pages - Promise.all([loadMessages(), loadApp()]).then( - ([lang, startReactApp]) => { - startReactApp(lang, undefined, undefined); + + const appStatePromise: Promise = new Promise(resolve => + loadAppState() + .then(data => resolve(data)) + .catch(() => resolve(undefined)) + ); + + Promise.all([loadMessages(), appStatePromise, loadApp()]).then( + ([lang, appState, startReactApp]) => { + startReactApp(lang, undefined, appState); }, error => { logError(error); diff --git a/server/sonar-web/src/main/js/helpers/analytics.js b/server/sonar-web/src/main/js/helpers/analytics.js new file mode 100644 index 00000000000..1531a816e9c --- /dev/null +++ b/server/sonar-web/src/main/js/helpers/analytics.js @@ -0,0 +1,27 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// The body of the `gtm` function comes from Google Tag Manager docs; let's keep it like it was written. +// @ts-ignore +// prettier-ignore +// eslint-disable-next-line +const gtm = id => (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});const f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);}(window,document,'script','dataLayer',id)); + +module.exports = { gtm }; -- 2.39.5