From 8bcc788c0e45ab79e0f4ce80cda3363bb4588399 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Thu, 23 May 2019 09:04:02 +0200 Subject: [PATCH] SONAR-12109 Web Analytics plugins (#1652) --- .../org/sonar/process/ProcessProperties.java | 1 - .../platformlevel/PlatformLevel4.java | 2 + .../sonar/server/ui/WebAnalyticsLoader.java} | 22 +-- .../server/ui/WebAnalyticsLoaderImpl.java | 54 +++++++ .../org/sonar/server/ui/ws/GlobalAction.java | 12 +- .../server/ui/WebAnalyticsLoaderImplTest.java | 89 ++++++++++++ .../sonar/server/ui/ws/GlobalActionTest.java | 25 +++- server/sonar-web/package.json | 1 - .../main/js/app/components/PageTracker.tsx | 71 ++++----- .../components/__tests__/PageTracker-test.tsx | 76 +++++----- .../__snapshots__/PageTracker-test.tsx.snap | 16 +++ .../app/components/extensions/Extension.tsx | 36 ++--- .../extensions/__tests__/Extension-test.tsx | 59 ++++++++ .../__snapshots__/Extension-test.tsx.snap | 25 ++++ .../js/app/components/extensions/utils.ts | 51 ------- .../components/nav/global/GlobalNavPlus.tsx | 4 +- server/sonar-web/src/main/js/app/index.ts | 3 +- server/sonar-web/src/main/js/app/types.d.ts | 1 + .../components/UpgradeOrganizationModal.tsx | 6 +- .../UpgradeOrganizationModal-test.tsx | 4 +- .../js/apps/create/organization/PlanStep.tsx | 2 +- .../organization/__tests__/PlanStep-test.tsx | 3 +- .../__snapshots__/extensions-test.ts.snap | 3 + .../js/helpers/__tests__/extensions-test.ts | 90 ++++++++++++ .../src/main/js/helpers/extensions.ts | 73 ++++++++++ server/sonar-web/yarn.lock | 5 - .../org/sonar/api/server/ws/WebService.java | 2 +- .../java/org/sonar/api/web/WebAnalytics.java | 136 ++++++++++++++++++ 28 files changed, 686 insertions(+), 186 deletions(-) rename server/{sonar-web/src/main/js/app/utils/installExtensionsHandler.ts => sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java} (69%) create mode 100644 server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoaderImpl.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/ui/WebAnalyticsLoaderImplTest.java create mode 100644 server/sonar-web/src/main/js/app/components/__tests__/__snapshots__/PageTracker-test.tsx.snap create mode 100644 server/sonar-web/src/main/js/app/components/extensions/__tests__/Extension-test.tsx create mode 100644 server/sonar-web/src/main/js/app/components/extensions/__tests__/__snapshots__/Extension-test.tsx.snap delete mode 100644 server/sonar-web/src/main/js/app/components/extensions/utils.ts create mode 100644 server/sonar-web/src/main/js/helpers/__tests__/__snapshots__/extensions-test.ts.snap create mode 100644 server/sonar-web/src/main/js/helpers/__tests__/extensions-test.ts create mode 100644 server/sonar-web/src/main/js/helpers/extensions.ts create mode 100644 sonar-plugin-api/src/main/java/org/sonar/api/web/WebAnalytics.java 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-web/src/main/js/app/utils/installExtensionsHandler.ts b/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java similarity index 69% rename from server/sonar-web/src/main/js/app/utils/installExtensionsHandler.ts rename to server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java index adf94c2d84a..ce2f54f3065 100644 --- a/server/sonar-web/src/main/js/app/utils/installExtensionsHandler.ts +++ b/server/sonar-server/src/main/java/org/sonar/server/ui/WebAnalyticsLoader.java @@ -17,16 +17,18 @@ * 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 = {}; +package org.sonar.server.ui; -const registerExtension = (key: string, start: Function) => { - extensions[key] = start; -}; +import java.util.Optional; +import org.sonar.api.server.ServerSide; -export default () => { - (window as any).registerExtension = registerExtension; -}; +@ServerSide +public interface WebAnalyticsLoader { -export const getExtensionFromCache = (key: string) => { - return extensions[key]; -}; + /** + * 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/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(); + +} -- 2.39.5