aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>2017-10-19 11:48:26 +0200
committerGrégoire Aubert <gregoire.aubert@sonarsource.com>2017-10-23 08:01:13 -0700
commitf03c5e9858dd86ecd78e6278aa6f95d146ada270 (patch)
treefa37c7b6cf0fb172449b98cc1d5647e4d8465f5e /server
parent6e71b7b4a1d26c0de2a2c8bde2b0274de90c08e0 (diff)
downloadsonarqube-f03c5e9858dd86ecd78e6278aa6f95d146ada270.tar.gz
sonarqube-f03c5e9858dd86ecd78e6278aa6f95d146ada270.zip
SONAR-9947 Update handling of manual installation of commercial editions
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/App.tsx55
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/AppContainer.tsx3
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/EditionBoxes.tsx46
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/__tests__/EditionBoxes-test.tsx15
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/EditionsStatusNotif.tsx40
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionForm.tsx11
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionSet.tsx22
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/__tests__/LicenseEditionForm-test.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/EditionsStatusNotif-test.tsx.snap43
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionForm-test.tsx.snap10
-rw-r--r--server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionSet-test.tsx.snap19
11 files changed, 150 insertions, 116 deletions
diff --git a/server/sonar-web/src/main/js/apps/marketplace/App.tsx b/server/sonar-web/src/main/js/apps/marketplace/App.tsx
index 37cc3b1d72a..0c008e09b7c 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/App.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/App.tsx
@@ -36,10 +36,10 @@ import {
Plugin,
PluginPending
} from '../../api/plugins';
-import { EditionStatus, getEditionStatus } from '../../api/marketplace';
+import { Edition, EditionStatus, getEditionsList, getEditionStatus } from '../../api/marketplace';
import { RawQuery } from '../../helpers/query';
import { translate } from '../../helpers/l10n';
-import { filterPlugins, parseQuery, Query, serializeQuery } from './utils';
+import { getEditionsForVersion, filterPlugins, parseQuery, Query, serializeQuery } from './utils';
export interface Props {
editionsUrl: string;
@@ -49,8 +49,10 @@ export interface Props {
}
interface State {
+ editions?: Edition[];
editionStatus?: EditionStatus;
- loading: boolean;
+ loadingEditions: boolean;
+ loadingPlugins: boolean;
pending: {
installing: PluginPending[];
updating: PluginPending[];
@@ -69,7 +71,8 @@ export default class App extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
- loading: true,
+ loadingEditions: true,
+ loadingPlugins: true,
pending: {
installing: [],
updating: [],
@@ -81,6 +84,7 @@ export default class App extends React.PureComponent<Props, State> {
componentDidMount() {
this.mounted = true;
+ this.fetchEditions();
this.fetchPendingPlugins();
this.fetchEditionStatus();
this.fetchQueryPlugins();
@@ -106,35 +110,35 @@ export default class App extends React.PureComponent<Props, State> {
};
fetchAllPlugins = () => {
- this.setState({ loading: true });
+ this.setState({ loadingPlugins: true });
Promise.all([getInstalledPluginsWithUpdates(), getAvailablePlugins()]).then(
([installed, available]) => {
if (this.mounted) {
this.setState({
- loading: false,
+ loadingPlugins: false,
plugins: sortBy(uniqBy([...installed, ...available.plugins], 'key'), 'name')
});
}
},
() => {
if (this.mounted) {
- this.setState({ loading: false });
+ this.setState({ loadingPlugins: false });
}
}
);
};
fetchUpdatesOnly = () => {
- this.setState({ loading: true });
+ this.setState({ loadingPlugins: true });
getPluginUpdates().then(
plugins => {
if (this.mounted) {
- this.setState({ loading: false, plugins });
+ this.setState({ loadingPlugins: false, plugins });
}
},
() => {
if (this.mounted) {
- this.setState({ loading: false });
+ this.setState({ loadingPlugins: false });
}
}
);
@@ -161,6 +165,25 @@ export default class App extends React.PureComponent<Props, State> {
() => {}
);
+ fetchEditions = () => {
+ this.setState({ loadingEditions: true });
+ getEditionsList(this.props.editionsUrl).then(
+ editionsPerVersion => {
+ if (this.mounted) {
+ this.setState({
+ editions: getEditionsForVersion(editionsPerVersion, this.props.sonarqubeVersion),
+ loadingEditions: false
+ });
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loadingEditions: false });
+ }
+ }
+ );
+ };
+
updateEditionStatus = (editionStatus: EditionStatus) =>
this.setState({ editionStatus: editionStatus });
@@ -170,15 +193,17 @@ export default class App extends React.PureComponent<Props, State> {
};
render() {
- const { editionStatus, loading, plugins, pending } = this.state;
+ const { editions, editionStatus, loadingPlugins, plugins, pending } = this.state;
const query = parseQuery(this.props.location.query);
const filteredPlugins = query.search ? filterPlugins(plugins, query.search) : plugins;
+
return (
<div className="page page-limited" id="marketplace-page">
<Helmet title={translate('marketplace.page')} />
<div className="marketplace-notifs">
{editionStatus && (
<EditionsStatusNotif
+ editions={editions}
editionStatus={editionStatus}
updateEditionStatus={this.updateEditionStatus}
/>
@@ -187,6 +212,8 @@ export default class App extends React.PureComponent<Props, State> {
</div>
<Header />
<EditionBoxes
+ editions={editions}
+ loading={this.state.loadingEditions}
editionStatus={editionStatus}
editionsUrl={this.props.editionsUrl}
sonarqubeVersion={this.props.sonarqubeVersion}
@@ -198,8 +225,8 @@ export default class App extends React.PureComponent<Props, State> {
updateCenterActive={this.props.updateCenterActive}
updateQuery={this.updateQuery}
/>
- {loading && <i className="spinner" />}
- {!loading && (
+ {loadingPlugins && <i className="spinner" />}
+ {!loadingPlugins && (
<PluginsList
plugins={filteredPlugins}
pending={pending}
@@ -207,7 +234,7 @@ export default class App extends React.PureComponent<Props, State> {
updateQuery={this.updateQuery}
/>
)}
- {!loading && <Footer total={filteredPlugins.length} />}
+ {!loadingPlugins && <Footer total={filteredPlugins.length} />}
</div>
);
}
diff --git a/server/sonar-web/src/main/js/apps/marketplace/AppContainer.tsx b/server/sonar-web/src/main/js/apps/marketplace/AppContainer.tsx
index 909f284fbdf..abf67e2c3d8 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/AppContainer.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/AppContainer.tsx
@@ -25,7 +25,8 @@ import './style.css';
const mapStateToProps = (state: any) => ({
editionsUrl: (getGlobalSettingValue(state, 'sonar.editions.jsonUrl') || {}).value,
sonarqubeVersion: getAppState(state).version,
- updateCenterActive: (getGlobalSettingValue(state, 'sonar.updatecenter.activate') || {}).value
+ updateCenterActive:
+ (getGlobalSettingValue(state, 'sonar.updatecenter.activate') || {}).value === 'true'
});
export default connect(mapStateToProps)(App as any);
diff --git a/server/sonar-web/src/main/js/apps/marketplace/EditionBoxes.tsx b/server/sonar-web/src/main/js/apps/marketplace/EditionBoxes.tsx
index 112205c8d36..95a3c96d109 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/EditionBoxes.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/EditionBoxes.tsx
@@ -22,58 +22,26 @@ import { FormattedMessage } from 'react-intl';
import EditionBox from './components/EditionBox';
import LicenseEditionForm from './components/LicenseEditionForm';
import UninstallEditionForm from './components/UninstallEditionForm';
-import { Edition, EditionStatus, getEditionsList } from '../../api/marketplace';
-import { getEditionsForVersion } from './utils';
+import { Edition, EditionStatus } from '../../api/marketplace';
import { translate } from '../../helpers/l10n';
export interface Props {
+ editions?: Edition[];
editionStatus?: EditionStatus;
editionsUrl: string;
+ loading: boolean;
sonarqubeVersion: string;
updateCenterActive: boolean;
updateEditionStatus: (editionStatus: EditionStatus) => void;
}
interface State {
- editions?: Edition[];
- editionsError: boolean;
- loading: boolean;
installEdition?: Edition;
openUninstallForm: boolean;
}
export default class EditionBoxes extends React.PureComponent<Props, State> {
- mounted: boolean;
- state: State = { editionsError: false, loading: true, openUninstallForm: false };
-
- componentDidMount() {
- this.mounted = true;
- this.fetchEditions();
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- fetchEditions = () => {
- this.setState({ loading: true });
- getEditionsList(this.props.editionsUrl).then(
- editionsPerVersion => {
- if (this.mounted) {
- this.setState({
- loading: false,
- editions: getEditionsForVersion(editionsPerVersion, this.props.sonarqubeVersion),
- editionsError: false
- });
- }
- },
- () => {
- if (this.mounted) {
- this.setState({ editionsError: true, loading: false });
- }
- }
- );
- };
+ state: State = { openUninstallForm: false };
handleOpenLicenseForm = (edition: Edition) => this.setState({ installEdition: edition });
handleCloseLicenseForm = () => this.setState({ installEdition: undefined });
@@ -82,13 +50,13 @@ export default class EditionBoxes extends React.PureComponent<Props, State> {
handleCloseUninstallForm = () => this.setState({ openUninstallForm: false });
render() {
- const { editionStatus } = this.props;
- const { editions, editionsError, loading, installEdition, openUninstallForm } = this.state;
+ const { editions, editionStatus, loading } = this.props;
+ const { installEdition, openUninstallForm } = this.state;
if (loading) {
return <i className="big-spacer-bottom spinner" />;
}
- if (!editions || editionsError) {
+ if (!editions) {
return (
<div className="spacer-bottom marketplace-editions">
<span className="alert alert-info">
diff --git a/server/sonar-web/src/main/js/apps/marketplace/__tests__/EditionBoxes-test.tsx b/server/sonar-web/src/main/js/apps/marketplace/__tests__/EditionBoxes-test.tsx
index e64c714577f..0df72ac7f89 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/__tests__/EditionBoxes-test.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/__tests__/EditionBoxes-test.tsx
@@ -48,27 +48,19 @@ const DEFAULT_EDITIONS = [
];
it('should display the edition boxes', () => {
- const wrapper = getWrapper();
+ const wrapper = getWrapper({ editions: DEFAULT_EDITIONS, loading: true });
expect(wrapper).toMatchSnapshot();
- wrapper.setState({
- editions: DEFAULT_EDITIONS,
- loading: false
- });
+ wrapper.setProps({ loading: false });
expect(wrapper).toMatchSnapshot();
});
it('should display an error message', () => {
const wrapper = getWrapper();
- wrapper.setState({ loading: false, editionsError: true });
expect(wrapper).toMatchSnapshot();
});
it('should open the license form', () => {
- const wrapper = getWrapper();
- wrapper.setState({
- editions: DEFAULT_EDITIONS,
- loading: false
- });
+ const wrapper = getWrapper({ editions: DEFAULT_EDITIONS });
(wrapper.instance() as EditionBoxes).handleOpenLicenseForm(DEFAULT_EDITIONS[0]);
expect(wrapper.find('LicenseEditionForm').exists()).toBeTruthy();
});
@@ -76,6 +68,7 @@ it('should open the license form', () => {
function getWrapper(props = {}) {
return shallow(
<EditionBoxes
+ loading={false}
editionStatus={DEFAULT_STATUS}
editionsUrl=""
sonarqubeVersion="6.7.5"
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/EditionsStatusNotif.tsx b/server/sonar-web/src/main/js/apps/marketplace/components/EditionsStatusNotif.tsx
index 1afc9931bf6..672a0b7a28b 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/components/EditionsStatusNotif.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/components/EditionsStatusNotif.tsx
@@ -20,10 +20,11 @@
import * as React from 'react';
import RestartForm from '../../../components/common/RestartForm';
import CloseIcon from '../../../components/icons-components/CloseIcon';
-import { dismissErrorMessage, EditionStatus } from '../../../api/marketplace';
+import { dismissErrorMessage, Edition, EditionStatus } from '../../../api/marketplace';
import { translate } from '../../../helpers/l10n';
interface Props {
+ editions?: Edition[];
editionStatus: EditionStatus;
updateEditionStatus: (editionStatus: EditionStatus) => void;
}
@@ -48,7 +49,10 @@ export default class EditionsStatusNotif extends React.PureComponent<Props, Stat
};
renderStatusAlert() {
- const { installationStatus } = this.props.editionStatus;
+ const { installationStatus, nextEditionKey } = this.props.editionStatus;
+ const nextEdition =
+ this.props.editions && this.props.editions.find(edition => edition.key === nextEditionKey);
+
switch (installationStatus) {
case 'AUTOMATIC_IN_PROGRESS':
return (
@@ -61,7 +65,13 @@ export default class EditionsStatusNotif extends React.PureComponent<Props, Stat
case 'UNINSTALL_IN_PROGRESS':
return (
<div className="alert alert-success">
- <span>{translate('marketplace.status', installationStatus)}</span>
+ <span>
+ {nextEdition ? (
+ translate('marketplace.status_x.' + installationStatus, nextEdition.name)
+ ) : (
+ translate('marketplace.status', installationStatus)
+ )}
+ </span>
<button className="js-restart spacer-left" onClick={this.handleOpenRestart}>
{translate('marketplace.restart')}
</button>
@@ -71,7 +81,27 @@ export default class EditionsStatusNotif extends React.PureComponent<Props, Stat
case 'MANUAL_IN_PROGRESS':
return (
<div className="alert alert-danger">
- {translate('marketplace.status', installationStatus)}
+ {nextEdition ? (
+ translate('marketplace.status_x.' + installationStatus, nextEdition.name)
+ ) : (
+ translate('marketplace.status', installationStatus)
+ )}
+ <p className="spacer-left">
+ {nextEdition && (
+ <a
+ className="button spacer-right"
+ download={`sonarqube-${nextEdition.name}.zip`}
+ href={nextEdition.download_link}
+ target="_blank">
+ {translate('marketplace.download_package')}
+ </a>
+ )}
+ <a
+ href="https://redirect.sonarsource.com/doc/how-to-install-an-edition.html"
+ target="_blank">
+ {translate('marketplace.how_to_install')}
+ </a>
+ </p>
<a className="little-spacer-left" href="https://www.sonarsource.com" target="_blank">
{translate('marketplace.how_to_install')}
</a>
@@ -96,7 +126,7 @@ export default class EditionsStatusNotif extends React.PureComponent<Props, Stat
</a>
</div>
)}
- {this.renderStatusAlert}
+ {this.renderStatusAlert()}
</div>
);
}
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionForm.tsx b/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionForm.tsx
index 6971889135c..e099465dc43 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionForm.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionForm.tsx
@@ -62,7 +62,7 @@ export default class LicenseEditionForm extends React.PureComponent<Props, State
handleConfirmClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
event.preventDefault();
const { license, status } = this.state;
- if (license && status && ['AUTOMATIC_INSTALL', 'NO_INSTALL'].includes(status)) {
+ if (license && status) {
this.setState({ submitting: true });
applyLicense({ license }).then(
editionStatus => {
@@ -102,10 +102,13 @@ export default class LicenseEditionForm extends React.PureComponent<Props, State
<footer className="modal-foot">
{submitting && <i className="spinner spacer-right" />}
- {status &&
- ['NO_INSTALL', 'AUTOMATIC_INSTALL'].includes(status) && (
+ {status && (
<button className="js-confirm" onClick={this.handleConfirmClick} disabled={submitting}>
- {status === 'NO_INSTALL' ? translate('save') : translate('marketplace.install')}
+ {status === 'AUTOMATIC_INSTALL' ? (
+ translate('marketplace.install')
+ ) : (
+ translate('save')
+ )}
</button>
)}
<a className="js-modal-close" href="#" onClick={this.handleCancelClick}>
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionSet.tsx b/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionSet.tsx
index 86c29fc65e9..496c5fe988d 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionSet.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/components/LicenseEditionSet.tsx
@@ -137,8 +137,7 @@ export default class LicenseEditionSet extends React.PureComponent<Props, State>
rows={6}
value={license}
/>
- {previewStatus &&
- licenseEdition && (
+ {previewStatus && (
<p
className={classNames('alert spacer-top', {
'alert-warning': previewStatus === 'AUTOMATIC_INSTALL',
@@ -147,24 +146,7 @@ export default class LicenseEditionSet extends React.PureComponent<Props, State>
})}>
{translateWithParameters(
'marketplace.license_preview_status.' + previewStatus,
- licenseEdition.name
- )}
- {previewStatus === 'MANUAL_INSTALL' && (
- <p className="spacer-top">
- <a
- className="button"
- download={`sonarqube-${licenseEdition.name}.zip`}
- href={licenseEdition.download_link}
- target="_blank">
- {translate('marketplace.download_package')}
- </a>
- <a
- className="spacer-left"
- href="https://redirect.sonarsource.com/doc/how-to-install-an-edition.html"
- target="_blank">
- {translate('marketplace.how_to_install')}
- </a>
- </p>
+ licenseEdition ? licenseEdition.name : translate('marketplace.commercial_edition')
)}
</p>
)}
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/LicenseEditionForm-test.tsx b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/LicenseEditionForm-test.tsx
index f1b0952650d..2beffa70f82 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/LicenseEditionForm-test.tsx
+++ b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/LicenseEditionForm-test.tsx
@@ -55,7 +55,7 @@ it('should correctly change the button based on the status', () => {
wrapper.setState({ status: 'AUTOMATIC_INSTALL' });
expect(wrapper.find('button')).toMatchSnapshot();
wrapper.setState({ status: 'MANUAL_INSTALL' });
- expect(wrapper.find('button').exists()).toBeFalsy();
+ expect(wrapper.find('button')).toMatchSnapshot();
});
it('should update the edition status after install', async () => {
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/EditionsStatusNotif-test.tsx.snap b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/EditionsStatusNotif-test.tsx.snap
index 0f79649b873..70b4ce43ef0 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/EditionsStatusNotif-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/EditionsStatusNotif-test.tsx.snap
@@ -1,10 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`should display a ready notification 1`] = `<div />`;
+exports[`should display a ready notification 1`] = `
+<div>
+ <div
+ className="alert alert-success"
+ >
+ <span>
+ marketplace.status.AUTOMATIC_READY
+ </span>
+ <button
+ className="js-restart spacer-left"
+ onClick={[Function]}
+ >
+ marketplace.restart
+ </button>
+ </div>
+</div>
+`;
exports[`should display an error notification 1`] = `<div />`;
-exports[`should display an in progress notif 1`] = `<div />`;
+exports[`should display an in progress notif 1`] = `
+<div>
+ <div
+ className="alert alert-info"
+ >
+ <i
+ className="spinner spacer-right text-bottom"
+ />
+ <span>
+ marketplace.status.AUTOMATIC_IN_PROGRESS
+ </span>
+ </div>
+</div>
+`;
exports[`should display install errors 1`] = `
<div>
@@ -20,5 +49,15 @@ exports[`should display install errors 1`] = `
<CloseIcon />
</a>
</div>
+ <div
+ className="alert alert-info"
+ >
+ <i
+ className="spinner spacer-right text-bottom"
+ />
+ <span>
+ marketplace.status.AUTOMATIC_IN_PROGRESS
+ </span>
+ </div>
</div>
`;
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionForm-test.tsx.snap
index e657d2c1ee2..e3951bfeaff 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionForm-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionForm-test.tsx.snap
@@ -20,6 +20,16 @@ exports[`should correctly change the button based on the status 2`] = `
</button>
`;
+exports[`should correctly change the button based on the status 3`] = `
+<button
+ className="js-confirm"
+ disabled={false}
+ onClick={[Function]}
+>
+ save
+</button>
+`;
+
exports[`should display correctly 1`] = `
<Modal
ariaHideApp={true}
diff --git a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionSet-test.tsx.snap b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionSet-test.tsx.snap
index 48c4cdecbf9..2e49983f7d3 100644
--- a/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionSet-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/marketplace/components/__tests__/__snapshots__/LicenseEditionSet-test.tsx.snap
@@ -21,25 +21,6 @@ exports[`should correctly display status message after checking license 3`] = `
className="alert spacer-top alert-danger"
>
marketplace.license_preview_status.MANUAL_INSTALL.Foo
- <p
- className="spacer-top"
- >
- <a
- className="button"
- download="sonarqube-Foo.zip"
- href="download_url"
- target="_blank"
- >
- marketplace.download_package
- </a>
- <a
- className="spacer-left"
- href="https://redirect.sonarsource.com/doc/how-to-install-an-edition.html"
- target="_blank"
- >
- marketplace.how_to_install
- </a>
- </p>
</p>
`;