]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-615 Add Google Tag Manager
authorSiegfried Ehret <49895321+siegfried-ehret-sonarsource@users.noreply.github.com>
Mon, 13 May 2019 14:25:18 +0000 (16:25 +0200)
committerSonarTech <sonartech@sonarsource.com>
Mon, 13 May 2019 18:21:18 +0000 (20:21 +0200)
server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java
server/sonar-server/src/main/java/org/sonar/server/ui/ws/GlobalAction.java
server/sonar-server/src/test/java/org/sonar/server/ui/ws/GlobalActionTest.java
server/sonar-web/src/main/js/app/components/App.tsx
server/sonar-web/src/main/js/app/components/PageTracker.tsx
server/sonar-web/src/main/js/app/components/SimpleSessionsContainer.tsx
server/sonar-web/src/main/js/app/components/__tests__/PageTracker-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/index.ts
server/sonar-web/src/main/js/helpers/analytics.js [new file with mode: 0644]

index 03c8655eb9bcd80f34e07f60aaee9669f30ec319..cf9dce23bd555e1a5bfae7bdab4ca7adca5f4d50 100644 (file)
@@ -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"),
index 2192601ac5ea91d34adbb359279d00c63c44b20a..de3f26f9f830a622f91b5c0d7d97e1e273e3b406 100644 (file)
@@ -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));
     }
   }
index 022910049322b97fc1c575cf67a3919a59207202..19b40373e8ba9451449519311b9264ab5d678c55 100644 (file)
@@ -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\"" +
       "  }" +
       "}");
index 1d9378e9c74f66dae213a18c758b389a601b0905..82f566cbcee2c5b6ce5d64d00b2c7752b4f6089e 100644 (file)
  */
 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<Props> {
   render() {
     return (
       <>
-        <Helmet defaultTitle={getInstance()}>
-          {this.props.enableGravatar && this.renderPreconnectLink()}
-        </Helmet>
-        <PageTracker />
+        <PageTracker>{this.props.enableGravatar && this.renderPreconnectLink()}</PageTracker>
         {this.props.children}
       </>
     );
index 9c8825be56f790965e52cff2fbc0e6b3f1a9ff55..322f2eb7c758cc41c146892818609f2eb2a35576 100644 (file)
@@ -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<Props> {
+interface State {
+  lastLocation?: string;
+}
+
+export class PageTracker extends React.Component<Props, State> {
+  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 (
+      <Helmet defaultTitle={getInstance()} {...tracking}>
+        {this.props.children}
+      </Helmet>
+    );
   }
 }
 
 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
   };
 };
 
index 3a1a52548edc6f094f64fd69b28ca45fb1f9e5fe..32965ae187dd45b8fa0198269ad9431b323e122f 100644 (file)
@@ -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 (
-    <div className="global-container">
-      <div className="page-wrapper" id="container">
-        {children}
+    <>
+      <PageTracker />
+
+      <div className="global-container">
+        <div className="page-wrapper" id="container">
+          {children}
+        </div>
+        <GlobalFooterContainer hideLoggedInInfo={true} />
       </div>
-      <GlobalFooterContainer hideLoggedInInfo={true} />
-    </div>
+    </>
   );
 }
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 (file)
index 0000000..cd87a8f
--- /dev/null
@@ -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<PageTracker['props']> = {}) {
+  return mount<PageTracker>(
+    <PageTracker
+      location={mockLocation()}
+      params={{}}
+      router={mockRouter()}
+      routes={[]}
+      {...props}
+    />
+  );
+}
index defd6a0c1d132ee0b685e9525cb4e296842a6cbd..60375997e7c55df4c357c2965fce204d9527ed22 100644 (file)
@@ -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<T.AppState> = 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 (file)
index 0000000..1531a81
--- /dev/null
@@ -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 };