From: Grégoire Aubert
Date: Thu, 23 May 2019 07:04:02 +0000 (+0200)
Subject: SONAR-12109 Web Analytics plugins (#1652)
X-Git-Tag: 7.8~162
X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=8bcc788c0e45ab79e0f4ce80cda3363bb4588399;p=sonarqube.git
SONAR-12109 Web Analytics plugins (#1652)
---
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 fd0a8b7dad2..a89a6863e9e 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
@@ -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"),
diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
index 47947ffb55a..e47bbbe1d28 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
@@ -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
index 00000000000..ce2f54f3065
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java
@@ -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 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
index 00000000000..b82267262cc
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoaderImpl.java
@@ -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 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 getUrlPathToJs() {
+ return Optional.ofNullable(analytics)
+ .map(WebAnalytics::getUrlPathToJs)
+ .filter(path -> !path.startsWith("/") && !path.contains("..") && !path.contains("://"))
+ .map(path -> "/" + path);
+ }
+}
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 de3f26f9f83..cd363fb12fd 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
@@ -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
index 00000000000..e7be923df8e
--- /dev/null
+++ b/server/sonar-server/src/test/java/org/sonar/server/ui/WebAnalyticsLoaderImplTest.java
@@ -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;
+ }
+}
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 19b40373e8b..918fb5af4bf 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
@@ -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();
}
diff --git a/server/sonar-web/package.json b/server/sonar-web/package.json
index aafb7aaa2f5..cca4351ea04 100644
--- a/server/sonar-web/package.json
+++ b/server/sonar-web/package.json
@@ -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",
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 322f2eb7c75..0af0e6bec16 100644
--- a/server/sonar-web/src/main/js/app/components/PageTracker.tsx
+++ b/server/sonar-web/src/main/js/app/components/PageTracker.tsx
@@ -18,35 +18,32 @@
* 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 {
- 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 {
}
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 (
-
+
{this.props.children}
);
}
}
-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
};
};
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
index cd87a8f48ef..59f6fe13655 100644
--- 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
@@ -18,61 +18,69 @@
* 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 = {}) {
- return mount(
-
- );
+ return shallow();
}
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
index 00000000000..ec84971fa84
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/PageTracker-test.tsx.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not trigger if no analytics system is given 1`] = `
+
+`;
+
+exports[`should work for WebAnalytics plugin 1`] = `
+
+`;
diff --git a/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx b/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx
index 8937b43f07a..2e66aa797d3 100644
--- a/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx
+++ b/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx
@@ -19,35 +19,29 @@
*/
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;
+ router: Router;
}
-type Props = OwnProps & WithRouterProps & InjectedIntlProps & StateProps & DispatchProps;
-
interface State {
extensionElement?: React.ReactElement;
}
-class Extension extends React.PureComponent {
+export class Extension extends React.PureComponent {
container?: HTMLElement | null;
stop?: Function;
state: State = {};
@@ -93,8 +87,7 @@ class Extension extends React.PureComponent {
};
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 {
}
}
-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(
+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
index 00000000000..42e27ab3a3e
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/Extension-test.tsx
@@ -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();
+ (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 = {}) {
+ return shallow(
+
+ );
+}
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
index 00000000000..39b613e960d
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render extension correctly 1`] = `
+
+`;
+
+exports[`should render extension correctly 2`] = `
+
+`;
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
index 4d6d3d3dff1..00000000000
--- a/server/sonar-web/src/main/js/app/components/extensions/utils.ts
+++ /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 {
- 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);
- });
-}
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
index 01401510728..ab374580b9d 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
@@ -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;
diff --git a/server/sonar-web/src/main/js/app/index.ts b/server/sonar-web/src/main/js/app/index.ts
index 60375997e7c..8fd4bc68a9d 100644
--- a/server/sonar-web/src/main/js/app/index.ts
+++ b/server/sonar-web/src/main/js/app/index.ts
@@ -17,13 +17,14 @@
* 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();
diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts
index 7ee7ed04cdd..685f85fc51c 100644
--- a/server/sonar-web/src/main/js/app/types.d.ts
+++ b/server/sonar-web/src/main/js/app/types.d.ts
@@ -102,6 +102,7 @@ declare namespace T {
settings: T.Dict;
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
index adf94c2d84a..00000000000
--- a/server/sonar-web/src/main/js/app/utils/installExtensionsHandler.ts
+++ /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 = {};
-
-const registerExtension = (key: string, start: Function) => {
- extensions[key] = start;
-};
-
-export default () => {
- (window as any).registerExtension = registerExtension;
-};
-
-export const getExtensionFromCache = (key: string) => {
- return extensions[key];
-};
diff --git a/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx
index 27467f3420f..4184908eb4d 100644
--- a/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx
+++ b/server/sonar-web/src/main/js/apps/create/components/UpgradeOrganizationModal.tsx
@@ -19,14 +19,14 @@
*/
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);
diff --git a/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationModal-test.tsx b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationModal-test.tsx
index e98729fb38e..292f352afe5 100644
--- a/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationModal-test.tsx
+++ b/server/sonar-web/src/main/js/apps/create/components/__tests__/UpgradeOrganizationModal-test.tsx
@@ -20,10 +20,10 @@
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)
}));
diff --git a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
index 3d23116b344..84d88a5e8a8 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/PlanStep.tsx
@@ -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);
diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
index 6baad0adb27..58a2f95af78 100644
--- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
+++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/PlanStep-test.tsx
@@ -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
index 00000000000..f535eda398a
--- /dev/null
+++ b/server/sonar-web/src/main/js/helpers/__tests__/__snapshots__/extensions-test.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`installScript should add the given script in the dom 1`] = `""`;
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
index 00000000000..72ece2f775b
--- /dev/null
+++ b/server/sonar-web/src/main/js/helpers/__tests__/extensions-test.ts
@@ -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
index 00000000000..66a56cf3ed0
--- /dev/null
+++ b/server/sonar-web/src/main/js/helpers/extensions.ts
@@ -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 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 {
+ 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();
+}
diff --git a/server/sonar-web/yarn.lock b/server/sonar-web/yarn.lock
index d693976e8b5..fbb636f62db 100644
--- a/server/sonar-web/yarn.lock
+++ b/server/sonar-web/yarn.lock
@@ -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"
diff --git a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/WebService.java b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/WebService.java
index 4119270f1f3..0ac828699c4 100644
--- a/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/WebService.java
+++ b/sonar-plugin-api/src/main/java/org/sonar/api/server/ws/WebService.java
@@ -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
index 00000000000..2284b5c8651
--- /dev/null
+++ b/sonar-plugin-api/src/main/java/org/sonar/api/web/WebAnalytics.java
@@ -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.
+ *
+ * See the method {@link #getUrlPathToJs()} for the details about specification.
+ *
+ *
+ * Example of implementation with a {@link org.sonar.api.server.ws.WebService}:
+ *
+ *
+ * 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);
+ * }
+ * }
+ * }
+ *
+ *
+ * @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.
+ *
+ *
+ * The returned path must not start with a slash '/' and must not contain ".." or "://".
+ *
+ *
+ *
+ * Examples:
+ *
+ * - "api/google/analytics" if file is generated by a {@link org.sonar.api.server.ws.WebService}.
+ * - "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").
+ *
+ *
+ *
+ *
+ * Webapp does not load the Javascript file if the URL does not return HTTP code 200.
+ *
+ *
+ *
+ * The Javascript file is composed of two parts:
+ *
+ * - the global code that is executed when the browser loads the webapp
+ * - the handler that is executed by the webapp when page is changed. See function "window.setWebAnalyticsPageChangeHandler()".
+ *
+ *
+ * Example for Matomo:
+ *
+ *
+ * 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"]);
+ * });
+ *
+ */
+ String getUrlPathToJs();
+
+}