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"),
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;
BackendCleanup.class,
IndexDefinitions.class,
WebPagesFilter.class,
+ WebAnalyticsLoaderImpl.class,
// batch
BatchWsModule.class,
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.ui;
+
+import java.util.Optional;
+import org.sonar.api.server.ServerSide;
+
+@ServerSide
+public interface WebAnalyticsLoader {
+
+ /**
+ * URL path to the JS file to be loaded by webapp, for instance "/static/foo/bar.js".
+ * It always starts with "/" and does not include the optional web context.
+ */
+ Optional<String> getUrlPathToJs();
+
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.ui;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.sonar.api.utils.MessageException;
+import org.sonar.api.web.WebAnalytics;
+
+public class WebAnalyticsLoaderImpl implements WebAnalyticsLoader {
+
+ @Nullable
+ private final WebAnalytics analytics;
+
+ public WebAnalyticsLoaderImpl(WebAnalytics[] analytics) {
+ if (analytics.length > 1) {
+ List<String> classes = Arrays.stream(analytics).map(a -> a.getClass().getName()).collect(Collectors.toList());
+ throw MessageException.of("Limited to only one web analytics plugin. Found multiple implementations: " + classes);
+ }
+ this.analytics = analytics.length == 1 ? analytics[0] : null;
+ }
+
+ public WebAnalyticsLoaderImpl() {
+ this.analytics = null;
+ }
+
+ @Override
+ public Optional<String> getUrlPathToJs() {
+ return Optional.ofNullable(analytics)
+ .map(WebAnalytics::getUrlPathToJs)
+ .filter(path -> !path.startsWith("/") && !path.contains("..") && !path.contains("://"))
+ .map(path -> "/" + path);
+ }
+}
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;
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;
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;
this.branchFeature = branchFeature;
this.userSession = userSession;
this.editionProvider = editionProvider;
+ this.webAnalyticsLoader = webAnalyticsLoader;
this.systemSettingValuesByKey = new HashMap<>();
}
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));
}
writeBranchSupport(json);
editionProvider.get().ifPresent(e -> json.prop("edition", e.name().toLowerCase(Locale.ENGLISH)));
json.prop("standalone", webServer.isStandalone());
+ writeWebAnalytics(json);
json.endObject();
}
}
json.prop("branchesEnabled", branchFeature.isEnabled());
}
+ private void writeWebAnalytics(JsonWriter json) {
+ webAnalyticsLoader.getUrlPathToJs().ifPresent(p -> json.prop("webAnalyticsJsPath", p));
+ }
}
--- /dev/null
+/*
+ * 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;
+ }
+}
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;
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;
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();
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\"" +
" }" +
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[] {});
}
}});
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();
}
"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",
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
-import * as GoogleAnalytics from 'react-ga';
-import { withRouter, WithRouterProps } from 'react-router';
import { connect } from 'react-redux';
import Helmet from 'react-helmet';
-import { getGlobalSettingValue, Store } from '../../store/rootReducer';
+import { Location, withRouter } from '../../components/hoc/withRouter';
import { gtm } from '../../helpers/analytics';
+import { installScript, getWebAnalyticsPageHandlerFromCache } from '../../helpers/extensions';
import { getInstance } from '../../helpers/system';
+import { getGlobalSettingValue, Store, getAppState } from '../../store/rootReducer';
-interface StateProps {
- trackingIdGA?: string;
+interface Props {
+ location: Location;
trackingIdGTM?: string;
+ webAnalytics?: string;
}
-type Props = WithRouterProps & StateProps;
-
interface State {
lastLocation?: string;
}
export class PageTracker extends React.Component<Props, State> {
- state: State = {
- lastLocation: undefined
- };
+ state: State = {};
componentDidMount() {
- const { trackingIdGA, trackingIdGTM } = this.props;
+ const { trackingIdGTM, webAnalytics } = this.props;
- if (trackingIdGA) {
- GoogleAnalytics.initialize(trackingIdGA);
+ if (webAnalytics && !getWebAnalyticsPageHandlerFromCache()) {
+ installScript(webAnalytics, 'head');
}
if (trackingIdGTM) {
}
trackPage = () => {
- const { location, trackingIdGA, trackingIdGTM } = this.props;
+ const { location, trackingIdGTM } = this.props;
const { lastLocation } = this.state;
-
- if (location.pathname !== lastLocation) {
- if (trackingIdGA) {
- // More info on the "title and page not in sync" issue: https://github.com/nfl/react-helmet/issues/189
- setTimeout(() => GoogleAnalytics.pageview(location.pathname), 500);
- }
-
- if (trackingIdGTM && location.pathname !== '/') {
- setTimeout(() => {
- const { dataLayer } = window as any;
- if (dataLayer && dataLayer.push) {
- dataLayer.push({ event: 'render-end' });
- }
- }, 500);
- }
-
- this.setState({
- lastLocation: location.pathname
- });
+ const { dataLayer } = window as any;
+ const locationChanged = location.pathname !== lastLocation;
+ const webAnalyticsPageChange = getWebAnalyticsPageHandlerFromCache();
+
+ if (webAnalyticsPageChange && locationChanged) {
+ this.setState({ lastLocation: location.pathname });
+ setTimeout(() => webAnalyticsPageChange(location.pathname), 500);
+ } else if (dataLayer && dataLayer.push && trackingIdGTM && location.pathname !== '/') {
+ this.setState({ lastLocation: location.pathname });
+ setTimeout(() => dataLayer.push({ event: 'render-end' }), 500);
}
};
render() {
- const { trackingIdGA, trackingIdGTM } = this.props;
- const tracking = {
- ...((trackingIdGA || trackingIdGTM) && { onChangeClientState: this.trackPage })
- };
+ const { trackingIdGTM, webAnalytics } = this.props;
return (
- <Helmet defaultTitle={getInstance()} {...tracking}>
+ <Helmet
+ defaultTitle={getInstance()}
+ onChangeClientState={trackingIdGTM || webAnalytics ? this.trackPage : undefined}>
{this.props.children}
</Helmet>
);
}
}
-const mapStateToProps = (state: Store): StateProps => {
- const trackingIdGA = getGlobalSettingValue(state, 'sonar.analytics.ga.trackingId');
+const mapStateToProps = (state: Store) => {
const trackingIdGTM = getGlobalSettingValue(state, 'sonar.analytics.gtm.trackingId');
-
return {
- trackingIdGA: trackingIdGA && trackingIdGA.value,
- trackingIdGTM: trackingIdGTM && trackingIdGTM.value
+ trackingIdGTM: trackingIdGTM && trackingIdGTM.value,
+ webAnalytics: getAppState(state).webAnalyticsJsPath
};
};
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { mount } from 'enzyme';
import * as React from 'react';
+import { shallow } from 'enzyme';
import { PageTracker } from '../PageTracker';
-import { mockLocation, mockRouter } from '../../../helpers/testMocks';
+import { gtm } from '../../../helpers/analytics';
+import { mockLocation } from '../../../helpers/testMocks';
+import { installScript, getWebAnalyticsPageHandlerFromCache } from '../../../helpers/extensions';
+
+jest.mock('../../../helpers/extensions', () => ({
+ installScript: jest.fn().mockResolvedValue({}),
+ getWebAnalyticsPageHandlerFromCache: jest.fn().mockReturnValue(undefined)
+}));
+
+jest.mock('../../../helpers/analytics', () => ({ gtm: jest.fn() }));
jest.useFakeTimers();
beforeEach(() => {
jest.clearAllTimers();
-
- (window as any).dataLayer = [];
-
- document.getElementsByTagName = jest.fn().mockImplementation(() => {
- return [document.body];
- });
+ jest.clearAllMocks();
});
it('should not trigger if no analytics system is given', () => {
- shallowRender();
-
- expect(setTimeout).not.toHaveBeenCalled();
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ expect(installScript).not.toHaveBeenCalled();
+ expect(gtm).not.toHaveBeenCalled();
});
-it('should work for Google Analytics', () => {
- const wrapper = shallowRender({ trackingIdGA: '123' });
- const instance = wrapper.instance();
- instance.trackPage();
+it('should work for WebAnalytics plugin', () => {
+ const pageChange = jest.fn();
+ const webAnalytics = '/static/pluginKey/web_analytics.js';
+ const wrapper = shallowRender({ webAnalytics });
+
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.find('HelmetWrapper').prop('onChangeClientState')).toBe(
+ wrapper.instance().trackPage
+ );
+ expect(installScript).toBeCalledWith(webAnalytics, 'head');
+ (getWebAnalyticsPageHandlerFromCache as jest.Mock).mockReturnValueOnce(pageChange);
- expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500);
+ wrapper.instance().trackPage();
+ jest.runAllTimers();
+ expect(pageChange).toHaveBeenCalledWith('/path');
});
it('should work for Google Tag Manager', () => {
+ (window as any).dataLayer = [];
+ const { dataLayer } = window as any;
+ const push = jest.spyOn(dataLayer, 'push');
const wrapper = shallowRender({ trackingIdGTM: '123' });
- const instance = wrapper.instance();
- const dataLayer = (window as any).dataLayer;
-
- expect(dataLayer).toHaveLength(1);
-
- instance.trackPage();
- expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500);
+ expect(wrapper.find('HelmetWrapper').prop('onChangeClientState')).toBe(
+ wrapper.instance().trackPage
+ );
+ expect(gtm).toBeCalled();
+ expect(dataLayer).toHaveLength(0);
+ wrapper.instance().trackPage();
jest.runAllTimers();
-
- expect(dataLayer).toHaveLength(2);
+ expect(push).toBeCalledWith({ event: 'render-end' });
+ expect(dataLayer).toHaveLength(1);
});
function shallowRender(props: Partial<PageTracker['props']> = {}) {
- return mount<PageTracker>(
- <PageTracker
- location={mockLocation()}
- params={{}}
- router={mockRouter()}
- routes={[]}
- {...props}
- />
- );
+ return shallow<PageTracker>(<PageTracker location={mockLocation()} {...props} />);
}
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not trigger if no analytics system is given 1`] = `
+<HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+/>
+`;
+
+exports[`should work for WebAnalytics plugin 1`] = `
+<HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ onChangeClientState={[Function]}
+/>
+`;
*/
import * as React from 'react';
import Helmet from 'react-helmet';
-import { withRouter, WithRouterProps } from 'react-router';
import { injectIntl, InjectedIntlProps } from 'react-intl';
import { connect } from 'react-redux';
-import { getExtensionStart } from './utils';
-import { translate } from '../../../helpers/l10n';
import getStore from '../../utils/getStore';
+import { getExtensionStart } from '../../../helpers/extensions';
import { addGlobalErrorMessage } from '../../../store/globalMessages';
+import { translate } from '../../../helpers/l10n';
import { Store, getCurrentUser } from '../../../store/rootReducer';
+import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
-interface OwnProps {
- extension: { key: string; name: string };
- options?: {};
-}
-
-interface StateProps {
+interface Props extends InjectedIntlProps {
currentUser: T.CurrentUser;
-}
-
-interface DispatchProps {
+ extension: T.Extension;
+ location: Location;
onFail: (message: string) => void;
+ options?: T.Dict<any>;
+ router: Router;
}
-type Props = OwnProps & WithRouterProps & InjectedIntlProps & StateProps & DispatchProps;
-
interface State {
extensionElement?: React.ReactElement<any>;
}
-class Extension extends React.PureComponent<Props, State> {
+export class Extension extends React.PureComponent<Props, State> {
container?: HTMLElement | null;
stop?: Function;
state: State = {};
};
startExtension() {
- const { extension } = this.props;
- getExtensionStart(extension.key).then(this.handleStart, this.handleFailure);
+ getExtensionStart(this.props.extension.key).then(this.handleStart, this.handleFailure);
}
stopExtension() {
}
}
-function mapStateToProps(state: Store): StateProps {
- return { currentUser: getCurrentUser(state) };
-}
-
-const mapDispatchToProps: DispatchProps = { onFail: addGlobalErrorMessage };
+const mapStateToProps = (state: Store) => ({ currentUser: getCurrentUser(state) });
+const mapDispatchToProps = { onFail: addGlobalErrorMessage };
-export default injectIntl<OwnProps & InjectedIntlProps>(
+export default injectIntl(
withRouter(
connect(
mapStateToProps,
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import { Extension } from '../Extension';
+import { mockCurrentUser, mockLocation, mockRouter } from '../../../../helpers/testMocks';
+import { getExtensionStart } from '../../../../helpers/extensions';
+import { waitAndUpdate } from '../../../../helpers/testUtils';
+
+jest.mock('../../../../helpers/extensions', () => ({
+ getExtensionStart: jest.fn().mockResolvedValue(jest.fn())
+}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+it('should render extension correctly', async () => {
+ const start = jest.fn().mockReturnValue(<div className="extension" />);
+ (getExtensionStart as jest.Mock).mockResolvedValue(start);
+
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ expect(getExtensionStart).toBeCalledWith('foo');
+ await waitAndUpdate(wrapper);
+ expect(start).toBeCalled();
+ expect(wrapper).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<Extension['props']> = {}) {
+ return shallow(
+ <Extension
+ currentUser={mockCurrentUser()}
+ extension={{ key: 'foo', name: 'Foo' }}
+ intl={{} as any}
+ location={mockLocation()}
+ onFail={jest.fn()}
+ router={mockRouter()}
+ {...props}
+ />
+ );
+}
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render extension correctly 1`] = `
+<div>
+ <HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="Foo"
+ />
+ <div />
+</div>
+`;
+
+exports[`should render extension correctly 2`] = `
+<div>
+ <HelmetWrapper
+ defer={true}
+ encodeSpecialCharacters={true}
+ title="Foo"
+ />
+ <div
+ className="extension"
+ />
+</div>
+`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import exposeLibraries from './exposeLibraries';
-import { getExtensionFromCache } from '../../utils/installExtensionsHandler';
-import { getBaseUrl } from '../../../helpers/urls';
-
-function installScript(key: string) {
- return new Promise(resolve => {
- exposeLibraries();
- const scriptTag = document.createElement('script');
- scriptTag.src = `${getBaseUrl()}/static/${key}.js`;
- scriptTag.onload = resolve;
- document.getElementsByTagName('body')[0].appendChild(scriptTag);
- });
-}
-
-export function getExtensionStart(key: string): Promise<Function> {
- return new Promise((resolve, reject) => {
- const fromCache = getExtensionFromCache(key);
- if (fromCache) {
- resolve(fromCache);
- return;
- }
-
- installScript(key).then(() => {
- const start = getExtensionFromCache(key);
- if (start) {
- resolve(start);
- } else {
- reject();
- }
- }, reject);
- });
-}
import CreateFormShim from '../../../../apps/portfolio/components/CreateFormShim';
import Dropdown from '../../../../components/controls/Dropdown';
import PlusIcon from '../../../../components/icons-components/PlusIcon';
-import { getExtensionStart } from '../../extensions/utils';
+import { OnboardingContextShape } from '../../OnboardingContext';
import { getComponentNavigation } from '../../../../api/nav';
+import { getExtensionStart } from '../../../../helpers/extensions';
import { translate } from '../../../../helpers/l10n';
import { isSonarCloud } from '../../../../helpers/system';
import { getPortfolioAdminUrl, getPortfolioUrl } from '../../../../helpers/urls';
import { hasGlobalPermission } from '../../../../helpers/users';
-import { OnboardingContextShape } from '../../OnboardingContext';
interface Props {
appState: Pick<T.AppState, 'qualifiers'>;
* 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();
settings: T.Dict<string>;
standalone?: boolean;
version: string;
+ webAnalyticsJsPath?: string;
}
export interface Branch {
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2019 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-const extensions: T.Dict<Function> = {};
-
-const registerExtension = (key: string, start: Function) => {
- extensions[key] = start;
-};
-
-export default () => {
- (window as any).registerExtension = registerExtension;
-};
-
-export const getExtensionFromCache = (key: string) => {
- return extensions[key];
-};
*/
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);
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)
}));
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);
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 () => {
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`installScript should add the given script in the dom 1`] = `"<script src=\\"custom_script.js\\"></script>"`;
--- /dev/null
+/*
+ * 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);
+ });
+});
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { getBaseUrl } from './urls';
+import exposeLibraries from '../app/components/extensions/exposeLibraries';
+
+const WEB_ANALYTICS_EXTENSION = 'sq-web-analytics';
+const extensions: T.Dict<Function> = {};
+
+function registerExtension(key: string, start: Function) {
+ extensions[key] = start;
+}
+
+function setWebAnalyticsPageChangeHandler(pageHandler: (pathname: string) => void) {
+ registerExtension(WEB_ANALYTICS_EXTENSION, pageHandler);
+}
+
+export function installExtensionsHandler() {
+ (window as any).registerExtension = registerExtension;
+}
+
+export function installWebAnalyticsHandler() {
+ (window as any).setWebAnalyticsPageChangeHandler = setWebAnalyticsPageChangeHandler;
+}
+
+export function getExtensionFromCache(key: string): Function | undefined {
+ return extensions[key];
+}
+
+export function getWebAnalyticsPageHandlerFromCache(): Function | undefined {
+ return extensions[WEB_ANALYTICS_EXTENSION];
+}
+
+export function installScript(url: string, target: 'body' | 'head' = 'body'): Promise<any> {
+ return new Promise(resolve => {
+ const scriptTag = document.createElement('script');
+ scriptTag.src = `${getBaseUrl()}${url}`;
+ scriptTag.onload = resolve;
+ document.getElementsByTagName(target)[0].appendChild(scriptTag);
+ });
+}
+
+export async function getExtensionStart(key: string) {
+ const fromCache = getExtensionFromCache(key);
+ if (fromCache) {
+ return Promise.resolve(fromCache);
+ }
+
+ exposeLibraries();
+ await installScript(`/static/${key}.js`);
+
+ const start = getExtensionFromCache(key);
+ if (start) {
+ return start;
+ }
+ return Promise.reject();
+}
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"
* .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"))
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.api.web;
+
+import org.sonar.api.ExtensionPoint;
+import org.sonar.api.server.ServerSide;
+
+/**
+ * Extension point to support a web analytics tool like Matomo or
+ * Google Analytics in the webapp.
+ *
+ * <p>See the method {@link #getUrlPathToJs()} for the details about specification.</p>
+ *
+ * <p>
+ * Example of implementation with a {@link org.sonar.api.server.ws.WebService}:
+ * </p>
+ * <pre>
+ * import java.io.IOException;
+ * import java.io.OutputStream;
+ * import java.nio.charset.StandardCharsets;
+ * import org.apache.commons.io.IOUtils;
+ * import org.sonar.api.server.ws.Request;
+ * import org.sonar.api.server.ws.RequestHandler;
+ * import org.sonar.api.server.ws.Response;
+ * import org.sonar.api.server.ws.WebService;
+ * import org.sonar.api.web.WebAnalytics;
+ *
+ * public class MyWebAnalytics implements WebAnalytics, WebService, RequestHandler {
+ *
+ * {@literal @}Override
+ * public String getUrlPathToJs() {
+ * return "api/myplugin/analytics";
+ * }
+ *
+ * {@literal @}Override
+ * public void define(Context context) {
+ * NewController controller = context.createController("api/myplugin");
+ * controller.createAction("analytics")
+ * .setInternal(false)
+ * .setHandler(this);
+ * controller.done();
+ * }
+ *
+ * {@literal @}Override
+ * public void handle(Request request, Response response) throws IOException {
+ * try (OutputStream output = response.stream()
+ * .setMediaType("application/javascript")
+ * .output()) {
+ * IOUtils.write("{replace with the javascript content}", output, StandardCharsets.UTF_8);
+ * }
+ * }
+ * }
+ * </pre>
+ *
+ * @since 7.8
+ */
+@ServerSide
+@ExtensionPoint
+public interface WebAnalytics {
+
+ /**
+ * Returns the URL path to the Javascript file that will be loaded by the webapp. File must be
+ * provided by SonarQube server and can't be located outside.
+ *
+ * <p>
+ * The returned path must not start with a slash '/' and must not contain ".." or "://".
+ * </p>
+ *
+ * <p>
+ * Examples:
+ * <ul>
+ * <li>"api/google/analytics" if file is generated by a {@link org.sonar.api.server.ws.WebService}.</li>
+ * <li>"static/myplugin/analytics.js" if file is bundled with the plugin with key "myplugin"
+ * (note that in this case the file in the plugin JAR is "static/analytics.js").</li>
+ * </ul>
+ * </p>
+ *
+ * <p>
+ * Webapp does not load the Javascript file if the URL does not return HTTP code 200.
+ * </p>
+ *
+ * <p>
+ * The Javascript file is composed of two parts:
+ * <ul>
+ * <li>the global code that is executed when the browser loads the webapp</li>
+ * <li>the handler that is executed by the webapp when page is changed. See function "window.setWebAnalyticsPageChangeHandler()".</li>
+ * </ul>
+ * </p>
+ * Example for Matomo:
+ *
+ * <pre>
+ * var _paq = window._paq || [];
+ * // tracker methods like "setCustomDimension" should be called before "trackPageView"
+ * _paq.push(["trackPageView"]);
+ * _paq.push(["enableLinkTracking"]);
+ * (function() {
+ * var u = "https://me.matomo.cloud/";
+ * _paq.push(["setTrackerUrl", u + "matomo.php"]);
+ * _paq.push(["setSiteId", "12345"]);
+ * var d = document,
+ * g = d.createElement("script"),
+ * s = d.getElementsByTagName("script")[0];
+ * g.type = "text/javascript";
+ * g.async = true;
+ * g.defer = true;
+ * g.src = "//cdn.matomo.cloud/me.matomo.cloud/matomo.js";
+ * s.parentNode.insertBefore(g, s);
+ * })();
+ *
+ * window.setWebAnalyticsPageChangeHandler(function(pathname) {
+ * _paq.push(["setCustomUrl", pathname]);
+ * _paq.push(["setDocumentTitle", document.title]);
+ * _paq.push(["trackPageView"]);
+ * });
+ * </pre>
+ */
+ String getUrlPathToJs();
+
+}