]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12109 Web Analytics plugins (#1652)
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Thu, 23 May 2019 07:04:02 +0000 (09:04 +0200)
committerSonarTech <sonartech@sonarsource.com>
Thu, 23 May 2019 18:21:09 +0000 (20:21 +0200)
29 files changed:
server/sonar-process/src/main/java/org/sonar/process/ProcessProperties.java
server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoaderImpl.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/ui/ws/GlobalAction.java
server/sonar-server/src/test/java/org/sonar/server/ui/WebAnalyticsLoaderImplTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/ui/ws/GlobalActionTest.java
server/sonar-web/package.json
server/sonar-web/src/main/js/app/components/PageTracker.tsx
server/sonar-web/src/main/js/app/components/__tests__/PageTracker-test.tsx
server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/PageTracker-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/extensions/Extension.tsx
server/sonar-web/src/main/js/app/components/extensions/__tests__/Extension-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/extensions/utils.ts [deleted file]
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
server/sonar-web/src/main/js/app/index.ts
server/sonar-web/src/main/js/app/types.d.ts
server/sonar-web/src/main/js/app/utils/installExtensionsHandler.ts [deleted file]
server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx
server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationModal-test.tsx
server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
server/sonar-web/src/main/js/helpers/__tests__/__snapshots__/extensions-test.ts.snap [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/__tests__/extensions-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/helpers/extensions.ts [new file with mode: 0644]
server/sonar-web/yarn.lock
sonar-plugin-api/src/main/java/org/sonar/api/server/ws/WebService.java
sonar-plugin-api/src/main/java/org/sonar/api/web/WebAnalytics.java [new file with mode: 0644]

index fd0a8b7dad2f592a87634eda43cc48b33441ba82..a89a6863e9e6efd2eea5b39d1fdc95b4a50c9933 100644 (file)
@@ -118,7 +118,6 @@ public class ProcessProperties {
     SONARCLOUD_ENABLED("sonar.sonarcloud.enabled", "false"),
     SONARCLOUD_HOMEPAGE_URL("sonar.homepage.url", ""),
     SONAR_PRISMIC_ACCESS_TOKEN("sonar.prismic.accessToken", ""),
-    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"),
 
index 47947ffb55a7f381b5bd7b986f8914b6a4050d8d..e47bbbe1d28d421edc8d5dbe963dd4b4c5485eae 100644 (file)
@@ -200,6 +200,7 @@ import org.sonar.server.text.MacroInterpreter;
 import org.sonar.server.ui.DeprecatedViews;
 import org.sonar.server.ui.PageDecorations;
 import org.sonar.server.ui.PageRepository;
+import org.sonar.server.ui.WebAnalyticsLoaderImpl;
 import org.sonar.server.ui.ws.NavigationWsModule;
 import org.sonar.server.updatecenter.UpdateCenterModule;
 import org.sonar.server.user.NewUserNotifier;
@@ -264,6 +265,7 @@ public class PlatformLevel4 extends PlatformLevel {
       BackendCleanup.class,
       IndexDefinitions.class,
       WebPagesFilter.class,
+      WebAnalyticsLoaderImpl.class,
 
       // batch
       BatchWsModule.class,
diff --git a/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java b/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java
new file mode 100644 (file)
index 0000000..ce2f54f
--- /dev/null
@@ -0,0 +1,34 @@
+/*
+ * 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.
+ */
+package org.sonar.server.ui;
+
+import java.util.Optional;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+public interface WebAnalyticsLoader {
+
+  /**
+   * URL path to the JS file to be loaded by webapp, for instance "/static/foo/bar.js".
+   * It always starts with "/" and does not include the optional web context.
+   */
+  Optional<String> getUrlPathToJs();
+
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoaderImpl.java b/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoaderImpl.java
new file mode 100644 (file)
index 0000000..b822672
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+package org.sonar.server.ui;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.sonar.api.utils.MessageException;
+import org.sonar.api.web.WebAnalytics;
+
+public class WebAnalyticsLoaderImpl implements WebAnalyticsLoader {
+
+  @Nullable
+  private final WebAnalytics analytics;
+
+  public WebAnalyticsLoaderImpl(WebAnalytics[] analytics) {
+    if (analytics.length > 1) {
+      List<String> classes = Arrays.stream(analytics).map(a -> a.getClass().getName()).collect(Collectors.toList());
+      throw MessageException.of("Limited to only one web analytics plugin. Found multiple implementations: " + classes);
+    }
+    this.analytics = analytics.length == 1 ? analytics[0] : null;
+  }
+
+  public WebAnalyticsLoaderImpl() {
+    this.analytics = null;
+  }
+
+  @Override
+  public Optional<String> getUrlPathToJs() {
+    return Optional.ofNullable(analytics)
+      .map(WebAnalytics::getUrlPathToJs)
+      .filter(path -> !path.startsWith("/") && !path.contains("..") && !path.contains("://"))
+      .map(path -> "/" + path);
+  }
+}
index de3f26f9f830a622f91b5c0d7d97e1e273e3b406..cd363fb12fd62bcc80c41c5210c86610ba94f105 100644 (file)
@@ -44,6 +44,7 @@ import org.sonar.server.organization.OrganizationFlags;
 import org.sonar.server.platform.WebServer;
 import org.sonar.server.ui.PageRepository;
 import org.sonar.server.ui.VersionFormatter;
+import org.sonar.server.ui.WebAnalyticsLoader;
 import org.sonar.server.user.UserSession;
 
 import static org.sonar.api.CoreProperties.RATING_GRID;
@@ -54,7 +55,6 @@ 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_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;
 
@@ -80,10 +80,12 @@ public class GlobalAction implements NavigationWsAction, Startable {
   private final BranchFeatureProxy branchFeature;
   private final UserSession userSession;
   private final PlatformEditionProvider editionProvider;
+  private final WebAnalyticsLoader webAnalyticsLoader;
 
   public GlobalAction(PageRepository pageRepository, Configuration config, ResourceTypes resourceTypes, Server server,
     WebServer webServer, DbClient dbClient, OrganizationFlags organizationFlags,
-    DefaultOrganizationProvider defaultOrganizationProvider, BranchFeatureProxy branchFeature, UserSession userSession, PlatformEditionProvider editionProvider) {
+    DefaultOrganizationProvider defaultOrganizationProvider, BranchFeatureProxy branchFeature, UserSession userSession, PlatformEditionProvider editionProvider,
+    WebAnalyticsLoader webAnalyticsLoader) {
     this.pageRepository = pageRepository;
     this.config = config;
     this.resourceTypes = resourceTypes;
@@ -95,6 +97,7 @@ public class GlobalAction implements NavigationWsAction, Startable {
     this.branchFeature = branchFeature;
     this.userSession = userSession;
     this.editionProvider = editionProvider;
+    this.webAnalyticsLoader = webAnalyticsLoader;
     this.systemSettingValuesByKey = new HashMap<>();
   }
 
@@ -104,7 +107,6 @@ 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_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));
     }
@@ -140,6 +142,7 @@ public class GlobalAction implements NavigationWsAction, Startable {
       writeBranchSupport(json);
       editionProvider.get().ifPresent(e -> json.prop("edition", e.name().toLowerCase(Locale.ENGLISH)));
       json.prop("standalone", webServer.isStandalone());
+      writeWebAnalytics(json);
       json.endObject();
     }
   }
@@ -199,4 +202,7 @@ public class GlobalAction implements NavigationWsAction, Startable {
     json.prop("branchesEnabled", branchFeature.isEnabled());
   }
 
+  private void writeWebAnalytics(JsonWriter json) {
+    webAnalyticsLoader.getUrlPathToJs().ifPresent(p -> json.prop("webAnalyticsJsPath", p));
+  }
 }
diff --git a/server/sonar-server/src/test/java/org/sonar/server/ui/WebAnalyticsLoaderImplTest.java b/server/sonar-server/src/test/java/org/sonar/server/ui/WebAnalyticsLoaderImplTest.java
new file mode 100644 (file)
index 0000000..e7be923
--- /dev/null
@@ -0,0 +1,89 @@
+/*
+ * 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.
+ */
+package org.sonar.server.ui;
+
+import org.junit.Test;
+import org.sonar.api.utils.MessageException;
+import org.sonar.api.web.WebAnalytics;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.catchThrowable;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class WebAnalyticsLoaderImplTest {
+
+  @Test
+  public void return_empty_if_no_analytics_plugin() {
+    assertThat(new WebAnalyticsLoaderImpl().getUrlPathToJs()).isEmpty();
+    assertThat(new WebAnalyticsLoaderImpl(new WebAnalytics[0]).getUrlPathToJs()).isEmpty();
+  }
+
+  @Test
+  public void return_js_path_if_analytics_plugin_is_installed() {
+    WebAnalytics analytics = newWebAnalytics("api/google/analytics");
+    WebAnalyticsLoaderImpl underTest = new WebAnalyticsLoaderImpl(new WebAnalytics[] {analytics});
+
+    assertThat(underTest.getUrlPathToJs()).hasValue("/api/google/analytics");
+  }
+
+  @Test
+  public void return_empty_if_path_starts_with_slash() {
+    WebAnalytics analytics = newWebAnalytics("/api/google/analytics");
+    WebAnalyticsLoaderImpl underTest = new WebAnalyticsLoaderImpl(new WebAnalytics[] {analytics});
+
+    assertThat(underTest.getUrlPathToJs()).isEmpty();
+  }
+
+  @Test
+  public void return_empty_if_path_is_an_url() {
+    WebAnalytics analytics = newWebAnalytics("http://foo");
+    WebAnalyticsLoaderImpl underTest = new WebAnalyticsLoaderImpl(new WebAnalytics[] {analytics});
+
+    assertThat(underTest.getUrlPathToJs()).isEmpty();
+  }
+
+  @Test
+  public void return_empty_if_path_has_up_operation() {
+    WebAnalytics analytics = newWebAnalytics("foo/../bar");
+    WebAnalyticsLoaderImpl underTest = new WebAnalyticsLoaderImpl(new WebAnalytics[] {analytics});
+
+    assertThat(underTest.getUrlPathToJs()).isEmpty();
+  }
+
+  @Test
+  public void fail_if_multiple_analytics_plugins_are_installed() {
+    WebAnalytics analytics1 = newWebAnalytics("foo");
+    WebAnalytics analytics2 = newWebAnalytics("bar");
+
+    Throwable thrown = catchThrowable(() -> new WebAnalyticsLoaderImpl(new WebAnalytics[] {analytics1, analytics2}));
+
+    assertThat(thrown)
+      .isInstanceOf(MessageException.class)
+      .hasMessage("Limited to only one web analytics plugin. Found multiple implementations: [" +
+        analytics1.getClass().getName() + ", " + analytics2.getClass().getName() + "]");
+  }
+
+  private static WebAnalytics newWebAnalytics(String path) {
+    WebAnalytics analytics = mock(WebAnalytics.class);
+    when(analytics.getUrlPathToJs()).thenReturn(path);
+    return analytics;
+  }
+}
index 19b40373e8ba9451449519311b9264ab5d678c55..918fb5af4bfb66bb8a2d8753fc7fea6a4d903f3b 100644 (file)
@@ -44,6 +44,7 @@ import org.sonar.server.organization.TestOrganizationFlags;
 import org.sonar.server.platform.WebServer;
 import org.sonar.server.tester.UserSessionRule;
 import org.sonar.server.ui.PageRepository;
+import org.sonar.server.ui.WebAnalyticsLoader;
 import org.sonar.server.ws.WsActionTester;
 import org.sonar.updatecenter.common.Version;
 
@@ -68,6 +69,7 @@ public class GlobalActionTest {
   private DefaultOrganizationProvider defaultOrganizationProvider = TestDefaultOrganizationProvider.fromUuid("foo");
   private BranchFeatureRule branchFeature = new BranchFeatureRule();
   private PlatformEditionProvider editionProvider = mock(PlatformEditionProvider.class);
+  private WebAnalyticsLoader webAnalyticsLoader = mock(WebAnalyticsLoader.class);
 
   private WsActionTester ws;
 
@@ -130,7 +132,6 @@ public class GlobalActionTest {
   public void return_sonarcloud_settings() {
     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,7 +139,6 @@ public class GlobalActionTest {
     assertJson(call()).isSimilarTo("{" +
       "  \"settings\": {" +
       "    \"sonar.prismic.accessToken\": \"secret\"," +
-      "    \"sonar.analytics.ga.trackingId\": \"ga_id\"," +
       "    \"sonar.analytics.gtm.trackingId\": \"gtm_id\"," +
       "    \"sonar.homepage.url\": \"https://s3/homepage.json\"" +
       "  }" +
@@ -313,6 +313,25 @@ public class GlobalActionTest {
     assertJson(json).isSimilarTo("{\"edition\":\"developer\"}");
   }
 
+  @Test
+  public void web_analytics_js_path_is_not_returned_if_not_defined() {
+    init();
+    when(webAnalyticsLoader.getUrlPathToJs()).thenReturn(Optional.empty());
+
+    String json = call();
+    assertThat(json).doesNotContain("webAnalyticsJsPath");
+  }
+
+  @Test
+  public void web_analytics_js_path_is_returned_if_defined() {
+    init();
+    String path = "static/googleanalytics/analytics.js";
+    when(webAnalyticsLoader.getUrlPathToJs()).thenReturn(Optional.of(path));
+
+    String json = call();
+    assertJson(json).isSimilarTo("{\"webAnalyticsJsPath\":\"" + path + "\"}");
+  }
+
   private void init() {
     init(new org.sonar.api.web.page.Page[] {}, new ResourceTypeTree[] {});
   }
@@ -332,7 +351,7 @@ public class GlobalActionTest {
     }});
     pageRepository.start();
     GlobalAction wsAction = new GlobalAction(pageRepository, settings.asConfig(), new ResourceTypes(resourceTypeTrees), server,
-      webServer, dbClient, organizationFlags, defaultOrganizationProvider, branchFeature, userSession, editionProvider);
+      webServer, dbClient, organizationFlags, defaultOrganizationProvider, branchFeature, userSession, editionProvider, webAnalyticsLoader);
     ws = new WsActionTester(wsAction);
     wsAction.start();
   }
index aafb7aaa2f56c91ce0c412edf85d6223477987a7..cca4351ea0447548f3c4c9bc21af1d90607f05ed 100644 (file)
@@ -29,7 +29,6 @@
     "react-day-picker": "7.3.0",
     "react-dom": "16.8.5",
     "react-draggable": "3.2.1",
-    "react-ga": "2.5.7",
     "react-helmet": "5.2.0",
     "react-intl": "2.8.0",
     "react-modal": "3.8.1",
index 322f2eb7c758cc41c146892818609f2eb2a35576..0af0e6bec16702517e871f28c44c8092a1d52021 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 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 { Location, withRouter } from '../../components/hoc/withRouter';
 import { gtm } from '../../helpers/analytics';
+import { installScript, getWebAnalyticsPageHandlerFromCache } from '../../helpers/extensions';
 import { getInstance } from '../../helpers/system';
+import { getGlobalSettingValue, Store, getAppState } from '../../store/rootReducer';
 
-interface StateProps {
-  trackingIdGA?: string;
+interface Props {
+  location: Location;
   trackingIdGTM?: string;
+  webAnalytics?: string;
 }
 
-type Props = WithRouterProps & StateProps;
-
 interface State {
   lastLocation?: string;
 }
 
 export class PageTracker extends React.Component<Props, State> {
-  state: State = {
-    lastLocation: undefined
-  };
+  state: State = {};
 
   componentDidMount() {
-    const { trackingIdGA, trackingIdGTM } = this.props;
+    const { trackingIdGTM, webAnalytics } = this.props;
 
-    if (trackingIdGA) {
-      GoogleAnalytics.initialize(trackingIdGA);
+    if (webAnalytics && !getWebAnalyticsPageHandlerFromCache()) {
+      installScript(webAnalytics, 'head');
     }
 
     if (trackingIdGTM) {
@@ -55,51 +52,39 @@ export class PageTracker extends React.Component<Props, State> {
   }
 
   trackPage = () => {
-    const { location, trackingIdGA, trackingIdGTM } = this.props;
+    const { location, 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
-      });
+    const { dataLayer } = window as any;
+    const locationChanged = location.pathname !== lastLocation;
+    const webAnalyticsPageChange = getWebAnalyticsPageHandlerFromCache();
+
+    if (webAnalyticsPageChange && locationChanged) {
+      this.setState({ lastLocation: location.pathname });
+      setTimeout(() => webAnalyticsPageChange(location.pathname), 500);
+    } else if (dataLayer && dataLayer.push && trackingIdGTM && location.pathname !== '/') {
+      this.setState({ lastLocation: location.pathname });
+      setTimeout(() => dataLayer.push({ event: 'render-end' }), 500);
     }
   };
 
   render() {
-    const { trackingIdGA, trackingIdGTM } = this.props;
-    const tracking = {
-      ...((trackingIdGA || trackingIdGTM) && { onChangeClientState: this.trackPage })
-    };
+    const { trackingIdGTM, webAnalytics } = this.props;
 
     return (
-      <Helmet defaultTitle={getInstance()} {...tracking}>
+      <Helmet
+        defaultTitle={getInstance()}
+        onChangeClientState={trackingIdGTM || webAnalytics ? this.trackPage : undefined}>
         {this.props.children}
       </Helmet>
     );
   }
 }
 
-const mapStateToProps = (state: Store): StateProps => {
-  const trackingIdGA = getGlobalSettingValue(state, 'sonar.analytics.ga.trackingId');
+const mapStateToProps = (state: Store) => {
   const trackingIdGTM = getGlobalSettingValue(state, 'sonar.analytics.gtm.trackingId');
-
   return {
-    trackingIdGA: trackingIdGA && trackingIdGA.value,
-    trackingIdGTM: trackingIdGTM && trackingIdGTM.value
+    trackingIdGTM: trackingIdGTM && trackingIdGTM.value,
+    webAnalytics: getAppState(state).webAnalyticsJsPath
   };
 };
 
index cd87a8f48ef0657c96d6b731d6f87c46de0f5ced..59f6fe13655d5afd42e1ceb37ea505f68586060c 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { mount } from 'enzyme';
 import * as React from 'react';
+import { shallow } from 'enzyme';
 import { PageTracker } from '../PageTracker';
-import { mockLocation, mockRouter } from '../../../helpers/testMocks';
+import { gtm } from '../../../helpers/analytics';
+import { mockLocation } from '../../../helpers/testMocks';
+import { installScript, getWebAnalyticsPageHandlerFromCache } from '../../../helpers/extensions';
+
+jest.mock('../../../helpers/extensions', () => ({
+  installScript: jest.fn().mockResolvedValue({}),
+  getWebAnalyticsPageHandlerFromCache: jest.fn().mockReturnValue(undefined)
+}));
+
+jest.mock('../../../helpers/analytics', () => ({ gtm: jest.fn() }));
 
 jest.useFakeTimers();
 
 beforeEach(() => {
   jest.clearAllTimers();
-
-  (window as any).dataLayer = [];
-
-  document.getElementsByTagName = jest.fn().mockImplementation(() => {
-    return [document.body];
-  });
+  jest.clearAllMocks();
 });
 
 it('should not trigger if no analytics system is given', () => {
-  shallowRender();
-
-  expect(setTimeout).not.toHaveBeenCalled();
+  const wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot();
+  expect(installScript).not.toHaveBeenCalled();
+  expect(gtm).not.toHaveBeenCalled();
 });
 
-it('should work for Google Analytics', () => {
-  const wrapper = shallowRender({ trackingIdGA: '123' });
-  const instance = wrapper.instance();
-  instance.trackPage();
+it('should work for WebAnalytics plugin', () => {
+  const pageChange = jest.fn();
+  const webAnalytics = '/static/pluginKey/web_analytics.js';
+  const wrapper = shallowRender({ webAnalytics });
+
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('HelmetWrapper').prop('onChangeClientState')).toBe(
+    wrapper.instance().trackPage
+  );
+  expect(installScript).toBeCalledWith(webAnalytics, 'head');
+  (getWebAnalyticsPageHandlerFromCache as jest.Mock).mockReturnValueOnce(pageChange);
 
-  expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500);
+  wrapper.instance().trackPage();
+  jest.runAllTimers();
+  expect(pageChange).toHaveBeenCalledWith('/path');
 });
 
 it('should work for Google Tag Manager', () => {
+  (window as any).dataLayer = [];
+  const { dataLayer } = window as any;
+  const push = jest.spyOn(dataLayer, 'push');
   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);
+  expect(wrapper.find('HelmetWrapper').prop('onChangeClientState')).toBe(
+    wrapper.instance().trackPage
+  );
+  expect(gtm).toBeCalled();
+  expect(dataLayer).toHaveLength(0);
 
+  wrapper.instance().trackPage();
   jest.runAllTimers();
-
-  expect(dataLayer).toHaveLength(2);
+  expect(push).toBeCalledWith({ event: 'render-end' });
+  expect(dataLayer).toHaveLength(1);
 });
 
 function shallowRender(props: Partial<PageTracker['props']> = {}) {
-  return mount<PageTracker>(
-    <PageTracker
-      location={mockLocation()}
-      params={{}}
-      router={mockRouter()}
-      routes={[]}
-      {...props}
-    />
-  );
+  return shallow<PageTracker>(<PageTracker location={mockLocation()} {...props} />);
 }
diff --git a/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/PageTracker-test.tsx.snap b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/PageTracker-test.tsx.snap
new file mode 100644 (file)
index 0000000..ec84971
--- /dev/null
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not trigger if no analytics system is given 1`] = `
+<HelmetWrapper
+  defer={true}
+  encodeSpecialCharacters={true}
+/>
+`;
+
+exports[`should work for WebAnalytics plugin 1`] = `
+<HelmetWrapper
+  defer={true}
+  encodeSpecialCharacters={true}
+  onChangeClientState={[Function]}
+/>
+`;
index 8937b43f07a9049dd2fc4550035231c9f893662b..2e66aa797d33510f19eff897e051df0c5934d27f 100644 (file)
  */
 import * as React from 'react';
 import Helmet from 'react-helmet';
-import { withRouter, WithRouterProps } from 'react-router';
 import { injectIntl, InjectedIntlProps } from 'react-intl';
 import { connect } from 'react-redux';
-import { getExtensionStart } from './utils';
-import { translate } from '../../../helpers/l10n';
 import getStore from '../../utils/getStore';
+import { getExtensionStart } from '../../../helpers/extensions';
 import { addGlobalErrorMessage } from '../../../store/globalMessages';
+import { translate } from '../../../helpers/l10n';
 import { Store, getCurrentUser } from '../../../store/rootReducer';
+import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
 
-interface OwnProps {
-  extension: { key: string; name: string };
-  options?: {};
-}
-
-interface StateProps {
+interface Props extends InjectedIntlProps {
   currentUser: T.CurrentUser;
-}
-
-interface DispatchProps {
+  extension: T.Extension;
+  location: Location;
   onFail: (message: string) => void;
+  options?: T.Dict<any>;
+  router: Router;
 }
 
-type Props = OwnProps & WithRouterProps & InjectedIntlProps & StateProps & DispatchProps;
-
 interface State {
   extensionElement?: React.ReactElement<any>;
 }
 
-class Extension extends React.PureComponent<Props, State> {
+export class Extension extends React.PureComponent<Props, State> {
   container?: HTMLElement | null;
   stop?: Function;
   state: State = {};
@@ -93,8 +87,7 @@ class Extension extends React.PureComponent<Props, State> {
   };
 
   startExtension() {
-    const { extension } = this.props;
-    getExtensionStart(extension.key).then(this.handleStart, this.handleFailure);
+    getExtensionStart(this.props.extension.key).then(this.handleStart, this.handleFailure);
   }
 
   stopExtension() {
@@ -118,13 +111,10 @@ class Extension extends React.PureComponent<Props, State> {
   }
 }
 
-function mapStateToProps(state: Store): StateProps {
-  return { currentUser: getCurrentUser(state) };
-}
-
-const mapDispatchToProps: DispatchProps = { onFail: addGlobalErrorMessage };
+const mapStateToProps = (state: Store) => ({ currentUser: getCurrentUser(state) });
+const mapDispatchToProps = { onFail: addGlobalErrorMessage };
 
-export default injectIntl<OwnProps & InjectedIntlProps>(
+export default injectIntl(
   withRouter(
     connect(
       mapStateToProps,
diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/Extension-test.tsx b/server/sonar-web/src/main/js/app/components/extensions/__tests__/Extension-test.tsx
new file mode 100644 (file)
index 0000000..42e27ab
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * 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 * as React from 'react';
+import { shallow } from 'enzyme';
+import { Extension } from '../Extension';
+import { mockCurrentUser, mockLocation, mockRouter } from '../../../../helpers/testMocks';
+import { getExtensionStart } from '../../../../helpers/extensions';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../helpers/extensions', () => ({
+  getExtensionStart: jest.fn().mockResolvedValue(jest.fn())
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+it('should render extension correctly', async () => {
+  const start = jest.fn().mockReturnValue(<div className="extension" />);
+  (getExtensionStart as jest.Mock).mockResolvedValue(start);
+
+  const wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot();
+  expect(getExtensionStart).toBeCalledWith('foo');
+  await waitAndUpdate(wrapper);
+  expect(start).toBeCalled();
+  expect(wrapper).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<Extension['props']> = {}) {
+  return shallow(
+    <Extension
+      currentUser={mockCurrentUser()}
+      extension={{ key: 'foo', name: 'Foo' }}
+      intl={{} as any}
+      location={mockLocation()}
+      onFail={jest.fn()}
+      router={mockRouter()}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap
new file mode 100644 (file)
index 0000000..39b613e
--- /dev/null
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render extension correctly 1`] = `
+<div>
+  <HelmetWrapper
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="Foo"
+  />
+  <div />
+</div>
+`;
+
+exports[`should render extension correctly 2`] = `
+<div>
+  <HelmetWrapper
+    defer={true}
+    encodeSpecialCharacters={true}
+    title="Foo"
+  />
+  <div
+    className="extension"
+  />
+</div>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/extensions/utils.ts b/server/sonar-web/src/main/js/app/components/extensions/utils.ts
deleted file mode 100644 (file)
index 4d6d3d3..0000000
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * 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 exposeLibraries from './exposeLibraries';
-import { getExtensionFromCache } from '../../utils/installExtensionsHandler';
-import { getBaseUrl } from '../../../helpers/urls';
-
-function installScript(key: string) {
-  return new Promise(resolve => {
-    exposeLibraries();
-    const scriptTag = document.createElement('script');
-    scriptTag.src = `${getBaseUrl()}/static/${key}.js`;
-    scriptTag.onload = resolve;
-    document.getElementsByTagName('body')[0].appendChild(scriptTag);
-  });
-}
-
-export function getExtensionStart(key: string): Promise<Function> {
-  return new Promise((resolve, reject) => {
-    const fromCache = getExtensionFromCache(key);
-    if (fromCache) {
-      resolve(fromCache);
-      return;
-    }
-
-    installScript(key).then(() => {
-      const start = getExtensionFromCache(key);
-      if (start) {
-        resolve(start);
-      } else {
-        reject();
-      }
-    }, reject);
-  });
-}
index 01401510728d387c162628d22fd3de4c1ff0c720..ab374580b9dce75f7b951ebb33c9d7f8ab65ab12 100644 (file)
@@ -22,13 +22,13 @@ import { Link, withRouter, WithRouterProps } from 'react-router';
 import CreateFormShim from '../../../../apps/portfolio/components/CreateFormShim';
 import Dropdown from '../../../../components/controls/Dropdown';
 import PlusIcon from '../../../../components/icons-components/PlusIcon';
-import { getExtensionStart } from '../../extensions/utils';
+import { OnboardingContextShape } from '../../OnboardingContext';
 import { getComponentNavigation } from '../../../../api/nav';
+import { getExtensionStart } from '../../../../helpers/extensions';
 import { translate } from '../../../../helpers/l10n';
 import { isSonarCloud } from '../../../../helpers/system';
 import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../helpers/urls';
 import { hasGlobalPermission } from '../../../../helpers/users';
-import { OnboardingContextShape } from '../../OnboardingContext';
 
 interface Props {
   appState: Pick<T.AppState, 'qualifiers'>;
index 60375997e7c55df4c357c2965fce204d9527ed22..8fd4bc68a9d5d448893eb7fc850c49b7fe4aa111 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import installExtensionsHandler from './utils/installExtensionsHandler';
 import { installGlobal, DEFAULT_LANGUAGE, requestMessages } from '../helpers/l10n';
+import { installExtensionsHandler, installWebAnalyticsHandler } from '../helpers/extensions';
 import { request, parseJSON } from '../helpers/request';
 import { getSystemStatus } from '../helpers/system';
 import './styles/sonar.css';
 
 installGlobal();
+installWebAnalyticsHandler();
 
 if (isMainApp()) {
   installExtensionsHandler();
index 7ee7ed04cdd64b1a794dafe165f6774315a56eb7..685f85fc51c0fb1376b11b1d2c0a53caf49edb35 100644 (file)
@@ -102,6 +102,7 @@ declare namespace T {
     settings: T.Dict<string>;
     standalone?: boolean;
     version: string;
+    webAnalyticsJsPath?: string;
   }
 
   export interface Branch {
diff --git a/server/sonar-web/src/main/js/app/utils/installExtensionsHandler.ts b/server/sonar-web/src/main/js/app/utils/installExtensionsHandler.ts
deleted file mode 100644 (file)
index adf94c2..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * 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.
- */
-const extensions: T.Dict<Function> = {};
-
-const registerExtension = (key: string, start: Function) => {
-  extensions[key] = start;
-};
-
-export default () => {
-  (window as any).registerExtension = registerExtension;
-};
-
-export const getExtensionFromCache = (key: string) => {
-  return extensions[key];
-};
index 27467f3420f9ea0b40a1de297d008443abdebce3..4184908eb4d610d0fe76d614b28611bbf68af816 100644 (file)
  */
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
-import UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages';
 import BillingFormShim from './BillingFormShim';
+import UpgradeOrganizationAdvantages from './UpgradeOrganizationAdvantages';
 import DeferredSpinner from '../../../components/common/DeferredSpinner';
 import Modal from '../../../components/controls/Modal';
 import { ResetButtonLink } from '../../../components/ui/buttons';
-import { getExtensionStart } from '../../../app/components/extensions/utils';
-import { translate } from '../../../helpers/l10n';
 import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
+import { getExtensionStart } from '../../../helpers/extensions';
+import { translate } from '../../../helpers/l10n';
 
 const BillingForm = withCurrentUser(BillingFormShim);
 
index e98729fb38e6d21c998b52a82cfebde227e4f91d..292f352afe5701fadec56b35a3b6d99b1469b6ff 100644 (file)
 import * as React from 'react';
 import { shallow } from 'enzyme';
 import UpgradeOrganizationModal from '../UpgradeOrganizationModal';
-import { getExtensionStart } from '../../../../app/components/extensions/utils';
+import { getExtensionStart } from '../../../../helpers/extensions';
 import { waitAndUpdate } from '../../../../helpers/testUtils';
 
-jest.mock('../../../../app/components/extensions/utils', () => ({
+jest.mock('../../../../helpers/extensions', () => ({
   getExtensionStart: jest.fn().mockResolvedValue(undefined)
 }));
 
index 3d23116b34495492be886a82321f0820518b1e38..84d88a5e8a88144fa1fa7f9bf5e96f5d6dc051df 100644 (file)
@@ -24,7 +24,7 @@ import DeferredSpinner from '../../../components/common/DeferredSpinner';
 import Step from '../../tutorials/components/Step';
 import { SubmitButton } from '../../../components/ui/buttons';
 import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
-import { getExtensionStart } from '../../../app/components/extensions/utils';
+import { getExtensionStart } from '../../../helpers/extensions';
 import { translate } from '../../../helpers/l10n';
 
 const BillingForm = withCurrentUser(BillingFormShim);
index 6baad0adb27eb0a439b61afdd19d88fd4f6ad396..58a2f95af78964dff1d51c56d44038330302043a 100644 (file)
@@ -24,10 +24,9 @@ import { Plan } from '../PlanSelect';
 import { mockAlmOrganization } from '../../../../helpers/testMocks';
 import { waitAndUpdate, submit } from '../../../../helpers/testUtils';
 
-jest.mock('../../../../app/components/extensions/utils', () => ({
+jest.mock('../../../../helpers/extensions', () => ({
   getExtensionStart: jest.fn().mockResolvedValue(undefined)
 }));
-
 const subscriptionPlans = [{ maxNcloc: 1000, price: 100 }];
 
 it('should render and use free plan', async () => {
diff --git a/server/sonar-web/src/main/js/helpers/__tests__/__snapshots__/extensions-test.ts.snap b/server/sonar-web/src/main/js/helpers/__tests__/__snapshots__/extensions-test.ts.snap
new file mode 100644 (file)
index 0000000..f535eda
--- /dev/null
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`installScript should add the given script in the dom 1`] = `"<script src=\\"custom_script.js\\"></script>"`;
diff --git a/server/sonar-web/src/main/js/helpers/__tests__/extensions-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/extensions-test.ts
new file mode 100644 (file)
index 0000000..72ece2f
--- /dev/null
@@ -0,0 +1,90 @@
+/*
+ * 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 {
+  getExtensionFromCache,
+  getWebAnalyticsPageHandlerFromCache,
+  getExtensionStart,
+  installExtensionsHandler,
+  installScript,
+  installWebAnalyticsHandler
+} from '../extensions';
+import exposeLibraries from '../../app/components/extensions/exposeLibraries';
+
+jest.mock('../../app/components/extensions/exposeLibraries', () => ({
+  default: jest.fn()
+}));
+
+beforeEach(() => {
+  jest.clearAllMocks();
+});
+
+describe('installExtensionsHandler & extensions.getExtensionFromCache', () => {
+  it('should register the global "registerExtension" function and retrieve extension', () => {
+    expect((window as any).registerExtension).toBeUndefined();
+    installExtensionsHandler();
+    expect((window as any).registerExtension).toEqual(expect.any(Function));
+
+    const start = jest.fn();
+    (window as any).registerExtension('foo', start);
+    expect(getExtensionFromCache('foo')).toBe(start);
+  });
+});
+
+describe('setWebAnalyticsPageChangeHandler & getWebAnalyticsPageHandlerFromCache', () => {
+  it('should register the global "setWebAnalyticsPageChangeHandler" function and retrieve analytics extension', () => {
+    expect((window as any).setWebAnalyticsPageChangeHandler).toBeUndefined();
+    installWebAnalyticsHandler();
+    expect((window as any).setWebAnalyticsPageChangeHandler).toEqual(expect.any(Function));
+
+    const pageChange = jest.fn();
+    (window as any).setWebAnalyticsPageChangeHandler(pageChange);
+    expect(getWebAnalyticsPageHandlerFromCache()).toBe(pageChange);
+  });
+});
+
+describe('installScript', () => {
+  it('should add the given script in the dom', () => {
+    installScript('custom_script.js');
+    expect(document.body.innerHTML).toMatchSnapshot();
+  });
+});
+
+describe('getExtensionStart', () => {
+  it('should install the extension in the to dom', () => {
+    const start = jest.fn();
+    const scriptTag = document.createElement('script');
+    document.createElement = jest.fn().mockReturnValue(scriptTag);
+    installExtensionsHandler();
+
+    const result = getExtensionStart('bar');
+    (window as any).registerExtension('bar', start);
+    (scriptTag.onload as Function)();
+
+    expect(exposeLibraries).toBeCalled();
+    return expect(result).resolves.toBe(start);
+  });
+
+  it('should get the extension from the cache', () => {
+    const start = jest.fn();
+    installExtensionsHandler();
+    (window as any).registerExtension('baz', start);
+    return expect(getExtensionStart('baz')).resolves.toBe(start);
+  });
+});
diff --git a/server/sonar-web/src/main/js/helpers/extensions.ts b/server/sonar-web/src/main/js/helpers/extensions.ts
new file mode 100644 (file)
index 0000000..66a56cf
--- /dev/null
@@ -0,0 +1,73 @@
+/*
+ * 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 { getBaseUrl } from './urls';
+import exposeLibraries from '../app/components/extensions/exposeLibraries';
+
+const WEB_ANALYTICS_EXTENSION = 'sq-web-analytics';
+const extensions: T.Dict<Function> = {};
+
+function registerExtension(key: string, start: Function) {
+  extensions[key] = start;
+}
+
+function setWebAnalyticsPageChangeHandler(pageHandler: (pathname: string) => void) {
+  registerExtension(WEB_ANALYTICS_EXTENSION, pageHandler);
+}
+
+export function installExtensionsHandler() {
+  (window as any).registerExtension = registerExtension;
+}
+
+export function installWebAnalyticsHandler() {
+  (window as any).setWebAnalyticsPageChangeHandler = setWebAnalyticsPageChangeHandler;
+}
+
+export function getExtensionFromCache(key: string): Function | undefined {
+  return extensions[key];
+}
+
+export function getWebAnalyticsPageHandlerFromCache(): Function | undefined {
+  return extensions[WEB_ANALYTICS_EXTENSION];
+}
+
+export function installScript(url: string, target: 'body' | 'head' = 'body'): Promise<any> {
+  return new Promise(resolve => {
+    const scriptTag = document.createElement('script');
+    scriptTag.src = `${getBaseUrl()}${url}`;
+    scriptTag.onload = resolve;
+    document.getElementsByTagName(target)[0].appendChild(scriptTag);
+  });
+}
+
+export async function getExtensionStart(key: string) {
+  const fromCache = getExtensionFromCache(key);
+  if (fromCache) {
+    return Promise.resolve(fromCache);
+  }
+
+  exposeLibraries();
+  await installScript(`/static/${key}.js`);
+
+  const start = getExtensionFromCache(key);
+  if (start) {
+    return start;
+  }
+  return Promise.reject();
+}
index d693976e8b5020caac368e5d007bc03ab67f135c..fbb636f62db787f86d742ae22d5837e240693a56 100644 (file)
@@ -8281,11 +8281,6 @@ react-fast-compare@^1.0.0:
   resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-1.0.0.tgz#813a039155e49b43ceffe99528fe5e9d97a6c938"
   integrity sha512-dcQpdWr62flXQJuM8/bVEY5/10ad2SYBUafp8H4q4WHR3fTA/MMlp8mpzX12I0CCoEJc1P6QdiMg7U+7lFS6Rw==
 
-react-ga@2.5.7:
-  version "2.5.7"
-  resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-2.5.7.tgz#1c80a289004bf84f84c26d46f3a6a6513081bf2e"
-  integrity sha512-UmATFaZpEQDO96KFjB5FRLcT6hFcwaxOmAJZnjrSiFN/msTqylq9G+z5Z8TYzN/dbamDTiWf92m6MnXXJkAivQ==
-
 react-helmet@5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-5.2.0.tgz#a81811df21313a6d55c5f058c4aeba5d6f3d97a7"
index 4119270f1f3e575d4164c3d3a6e4edcaa2aade0b..0ac828699c4af278444a40cfc365590fd4fe2b56 100644 (file)
@@ -71,7 +71,7 @@ import static java.util.Objects.requireNonNull;
  *       .setHandler(new RequestHandler() {
  *         {@literal @}Override
  *         public void handle(Request request, Response response) {
- *           // read request parameters and generates response output
+ *           // read request parameters and generate response output
  *           response.newJsonWriter()
  *             .beginObject()
  *             .prop("hello", request.mandatoryParam("key"))
diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/web/WebAnalytics.java b/sonar-plugin-api/src/main/java/org/sonar/api/web/WebAnalytics.java
new file mode 100644 (file)
index 0000000..2284b5c
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * 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.
+ */
+package org.sonar.api.web;
+
+import org.sonar.api.ExtensionPoint;
+import org.sonar.api.server.ServerSide;
+
+/**
+ * Extension point to support a web analytics tool like Matomo or
+ * Google Analytics in the webapp.
+ *
+ * <p>See the method {@link #getUrlPathToJs()} for the details about specification.</p>
+ *
+ * <p>
+ * Example of implementation with a {@link org.sonar.api.server.ws.WebService}:
+ * </p>
+ * <pre>
+ * import java.io.IOException;
+ * import java.io.OutputStream;
+ * import java.nio.charset.StandardCharsets;
+ * import org.apache.commons.io.IOUtils;
+ * import org.sonar.api.server.ws.Request;
+ * import org.sonar.api.server.ws.RequestHandler;
+ * import org.sonar.api.server.ws.Response;
+ * import org.sonar.api.server.ws.WebService;
+ * import org.sonar.api.web.WebAnalytics;
+ *
+ * public class MyWebAnalytics implements WebAnalytics, WebService, RequestHandler {
+ *
+ *   {@literal @}Override
+ *   public String getUrlPathToJs() {
+ *     return "api/myplugin/analytics";
+ *   }
+ *
+ *   {@literal @}Override
+ *   public void define(Context context) {
+ *     NewController controller = context.createController("api/myplugin");
+ *     controller.createAction("analytics")
+ *       .setInternal(false)
+ *       .setHandler(this);
+ *     controller.done();
+ *   }
+ *
+ *   {@literal @}Override
+ *   public void handle(Request request, Response response) throws IOException {
+ *     try (OutputStream output = response.stream()
+ *       .setMediaType("application/javascript")
+ *       .output()) {
+ *       IOUtils.write("{replace with the javascript content}", output, StandardCharsets.UTF_8);
+ *     }
+ *   }
+ * }
+ * </pre>
+ *
+ * @since 7.8
+ */
+@ServerSide
+@ExtensionPoint
+public interface WebAnalytics {
+
+  /**
+   * Returns the URL path to the Javascript file that will be loaded by the webapp. File must be
+   * provided by SonarQube server and can't be located outside.
+   *
+   * <p>
+   * The returned path must not start with a slash '/' and must not contain ".." or "://".
+   * </p>
+   *
+   * <p>
+   * Examples:
+   * <ul>
+   *  <li>"api/google/analytics" if file is generated by a {@link org.sonar.api.server.ws.WebService}.</li>
+   *  <li>"static/myplugin/analytics.js" if file is bundled with the plugin with key "myplugin"
+   *   (note that in this case the file in the plugin JAR is "static/analytics.js").</li>
+   * </ul>
+   * </p>
+   *
+   * <p>
+   * Webapp does not load the Javascript file if the URL does not return HTTP code 200.
+   * </p>
+   *
+   * <p>
+   * The Javascript file is composed of two parts:
+   * <ul>
+   *   <li>the global code that is executed when the browser loads the webapp</li>
+   *   <li>the handler that is executed by the webapp when page is changed. See function "window.setWebAnalyticsPageChangeHandler()".</li>
+   * </ul>
+   * </p>
+   * Example for Matomo:
+   *
+   * <pre>
+   * var _paq = window._paq || [];
+   * // tracker methods like "setCustomDimension" should be called before "trackPageView"
+   * _paq.push(["trackPageView"]);
+   * _paq.push(["enableLinkTracking"]);
+   * (function() {
+   *   var u = "https://me.matomo.cloud/";
+   *   _paq.push(["setTrackerUrl", u + "matomo.php"]);
+   *   _paq.push(["setSiteId", "12345"]);
+   *   var d = document,
+   *     g = d.createElement("script"),
+   *     s = d.getElementsByTagName("script")[0];
+   *   g.type = "text/javascript";
+   *   g.async = true;
+   *   g.defer = true;
+   *   g.src = "//cdn.matomo.cloud/me.matomo.cloud/matomo.js";
+   *   s.parentNode.insertBefore(g, s);
+   * })();
+   *
+   * window.setWebAnalyticsPageChangeHandler(function(pathname) {
+   *   _paq.push(["setCustomUrl", pathname]);
+   *   _paq.push(["setDocumentTitle", document.title]);
+   *   _paq.push(["trackPageView"]);
+   * });
+   * </pre>
+   */
+  String getUrlPathToJs();
+
+}