aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2021-06-11 10:33:13 +0200
committersonartech <sonartech@sonarsource.com>2021-06-17 20:03:07 +0000
commitef084e20eb019c2a8f37b4e7827c5307bc1e2e5d (patch)
treecc7a088d051276d6a68fd9be8ce8dfbe9072bab9
parent96d6d7532cb485deb8b95865fc43144b33e2f202 (diff)
downloadsonarqube-ef084e20eb019c2a8f37b4e7827c5307bc1e2e5d.tar.gz
sonarqube-ef084e20eb019c2a8f37b4e7827c5307bc1e2e5d.zip
SONAR-14935 Prompt users for next steps once the first analysis is finished
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx15
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx88
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx131
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-test.tsx28
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverviewRenderer-test.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/__tests__/FirstAnalysisNextStepsNotif-test.tsx72
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverview-test.tsx.snap6
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverviewRenderer-test.tsx.snap582
-rw-r--r--server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/FirstAnalysisNextStepsNotif-test.tsx.snap146
-rw-r--r--server/sonar-web/src/main/js/apps/overview/components/App.tsx25
-rw-r--r--server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx9
-rw-r--r--server/sonar-web/src/main/js/components/ui/DismissableAlert.css29
-rw-r--r--server/sonar-web/src/main/js/components/ui/DismissableAlert.tsx69
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/DismissableAlert-test.tsx65
-rw-r--r--server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/DismissableAlert-test.tsx.snap59
-rw-r--r--server/sonar-web/src/main/js/types/types.d.ts1
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties9
17 files changed, 1039 insertions, 301 deletions
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx
index 7ea3332157a..c1ccd55b86d 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx
@@ -42,6 +42,7 @@ import {
extractStatusConditionsFromApplicationStatusChildProject,
extractStatusConditionsFromProjectStatus
} from '../../../helpers/qualityGates';
+import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
import { ApplicationPeriod } from '../../../types/application';
import { Branch, BranchLike } from '../../../types/branch-like';
import { ComponentQualifier } from '../../../types/component';
@@ -54,12 +55,15 @@ import BranchOverviewRenderer from './BranchOverviewRenderer';
interface Props {
branch?: Branch;
+ branchesEnabled?: boolean;
component: T.Component;
+ projectBinding?: ProjectAlmBindingResponse;
}
interface State {
analyses?: T.Analysis[];
appLeak?: ApplicationPeriod;
+ detectedCIOnLastAnalysis?: boolean;
graph: GraphType;
loadingHistory?: boolean;
loadingStatus?: boolean;
@@ -71,6 +75,7 @@ interface State {
}
export const BRANCH_OVERVIEW_ACTIVITY_GRAPH = 'sonar_branch_overview.graph';
+export const NO_CI_DETECTED = 'undetected';
// Get all history data over the past year.
const FROM_DATE = toNotSoISOString(new Date().setFullYear(new Date().getFullYear() - 1));
@@ -331,6 +336,10 @@ export default class BranchOverview extends React.PureComponent<Props, State> {
({ analyses }) => {
if (this.mounted) {
this.setState({
+ detectedCIOnLastAnalysis:
+ analyses.length > 0
+ ? analyses[0].detectedCI !== undefined && analyses[0].detectedCI !== NO_CI_DETECTED
+ : undefined,
analyses
});
}
@@ -388,10 +397,11 @@ export default class BranchOverview extends React.PureComponent<Props, State> {
};
render() {
- const { branch, component } = this.props;
+ const { branch, branchesEnabled, component, projectBinding } = this.props;
const {
analyses,
appLeak,
+ detectedCIOnLastAnalysis,
graph,
loadingStatus,
loadingHistory,
@@ -414,7 +424,9 @@ export default class BranchOverview extends React.PureComponent<Props, State> {
analyses={analyses}
appLeak={appLeak}
branch={branch}
+ branchesEnabled={branchesEnabled}
component={component}
+ detectedCIOnLastAnalysis={detectedCIOnLastAnalysis}
graph={graph}
loadingHistory={loadingHistory}
loadingStatus={loadingStatus}
@@ -423,6 +435,7 @@ export default class BranchOverview extends React.PureComponent<Props, State> {
metrics={metrics}
onGraphChange={this.handleGraphChange}
period={period}
+ projectBinding={projectBinding}
projectIsEmpty={projectIsEmpty}
qgStatuses={qgStatuses}
/>
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx
index e4a40cc4253..52cf4428bbf 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx
@@ -20,12 +20,14 @@
import * as React from 'react';
import { parseDate } from 'sonar-ui-common/helpers/dates';
import A11ySkipTarget from '../../../app/components/a11y/A11ySkipTarget';
+import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
import { ApplicationPeriod } from '../../../types/application';
import { Branch } from '../../../types/branch-like';
import { ComponentQualifier } from '../../../types/component';
import { GraphType, MeasureHistory } from '../../../types/project-activity';
import { QualityGateStatus } from '../../../types/quality-gates';
import ActivityPanel from './ActivityPanel';
+import FirstAnalysisNextStepsNotif from './FirstAnalysisNextStepsNotif';
import { MeasuresPanel } from './MeasuresPanel';
import NoCodeWarning from './NoCodeWarning';
import QualityGatePanel from './QualityGatePanel';
@@ -34,7 +36,9 @@ export interface BranchOverviewRendererProps {
analyses?: T.Analysis[];
appLeak?: ApplicationPeriod;
branch?: Branch;
+ branchesEnabled?: boolean;
component: T.Component;
+ detectedCIOnLastAnalysis?: boolean;
graph?: GraphType;
loadingHistory?: boolean;
loadingStatus?: boolean;
@@ -43,6 +47,7 @@ export interface BranchOverviewRendererProps {
metrics?: T.Metric[];
onGraphChange: (graph: GraphType) => void;
period?: T.Period;
+ projectBinding?: ProjectAlmBindingResponse;
projectIsEmpty?: boolean;
qgStatuses?: QualityGateStatus[];
}
@@ -52,7 +57,9 @@ export function BranchOverviewRenderer(props: BranchOverviewRendererProps) {
analyses,
appLeak,
branch,
+ branchesEnabled,
component,
+ detectedCIOnLastAnalysis,
graph,
loadingHistory,
loadingStatus,
@@ -61,6 +68,7 @@ export function BranchOverviewRenderer(props: BranchOverviewRendererProps) {
metrics = [],
onGraphChange,
period,
+ projectBinding,
projectIsEmpty,
qgStatuses
} = props;
@@ -68,50 +76,58 @@ export function BranchOverviewRenderer(props: BranchOverviewRendererProps) {
const leakPeriod = component.qualifier === ComponentQualifier.Application ? appLeak : period;
return (
- <div className="page page-limited">
- <div className="overview">
- <A11ySkipTarget anchor="overview_main" />
+ <>
+ <FirstAnalysisNextStepsNotif
+ component={component}
+ branchesEnabled={branchesEnabled}
+ detectedCIOnLastAnalysis={detectedCIOnLastAnalysis}
+ projectBinding={projectBinding}
+ />
+ <div className="page page-limited">
+ <div className="overview">
+ <A11ySkipTarget anchor="overview_main" />
- {projectIsEmpty ? (
- <NoCodeWarning branchLike={branch} component={component} measures={measures} />
- ) : (
- <div className="display-flex-row">
- <div className="width-25 big-spacer-right">
- <QualityGatePanel
- component={component}
- loading={loadingStatus}
- qgStatuses={qgStatuses}
- />
- </div>
-
- <div className="flex-1">
- <div className="display-flex-column">
- <MeasuresPanel
- appLeak={appLeak}
- branch={branch}
+ {projectIsEmpty ? (
+ <NoCodeWarning branchLike={branch} component={component} measures={measures} />
+ ) : (
+ <div className="display-flex-row">
+ <div className="width-25 big-spacer-right">
+ <QualityGatePanel
component={component}
loading={loadingStatus}
- measures={measures}
- period={period}
+ qgStatuses={qgStatuses}
/>
+ </div>
- <ActivityPanel
- analyses={analyses}
- branchLike={branch}
- component={component}
- graph={graph}
- leakPeriodDate={leakPeriod && parseDate(leakPeriod.date)}
- loading={loadingHistory}
- measuresHistory={measuresHistory}
- metrics={metrics}
- onGraphChange={onGraphChange}
- />
+ <div className="flex-1">
+ <div className="display-flex-column">
+ <MeasuresPanel
+ appLeak={appLeak}
+ branch={branch}
+ component={component}
+ loading={loadingStatus}
+ measures={measures}
+ period={period}
+ />
+
+ <ActivityPanel
+ analyses={analyses}
+ branchLike={branch}
+ component={component}
+ graph={graph}
+ leakPeriodDate={leakPeriod && parseDate(leakPeriod.date)}
+ loading={loadingHistory}
+ measuresHistory={measuresHistory}
+ metrics={metrics}
+ onGraphChange={onGraphChange}
+ />
+ </div>
</div>
</div>
- </div>
- )}
+ )}
+ </div>
</div>
- </div>
+ </>
);
}
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx b/server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx
new file mode 100644
index 00000000000..c345eba2247
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx
@@ -0,0 +1,131 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { withCurrentUser } from '../../../components/hoc/withCurrentUser';
+import DismissableAlert from '../../../components/ui/DismissableAlert';
+import { isLoggedIn } from '../../../helpers/users';
+import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
+import { PULL_REQUEST_DECORATION_BINDING_CATEGORY } from '../../settings/components/AdditionalCategoryKeys';
+
+export interface FirstAnalysisNextStepsNotifProps {
+ branchesEnabled?: boolean;
+ component: T.Component;
+ currentUser: T.CurrentUser;
+ detectedCIOnLastAnalysis?: boolean;
+ projectBinding?: ProjectAlmBindingResponse;
+}
+
+export function FirstAnalysisNextStepsNotif(props: FirstAnalysisNextStepsNotifProps) {
+ const {
+ component,
+ currentUser,
+ branchesEnabled,
+ detectedCIOnLastAnalysis,
+ projectBinding
+ } = props;
+
+ if (!isLoggedIn(currentUser)) {
+ return null;
+ }
+
+ const showConfigurePullRequestDecoNotif = branchesEnabled && projectBinding === undefined;
+ const showConfigureCINotif =
+ detectedCIOnLastAnalysis !== undefined ? !detectedCIOnLastAnalysis : false;
+
+ if (!showConfigureCINotif && !showConfigurePullRequestDecoNotif) {
+ return null;
+ }
+
+ const showOnlyConfigureCI = showConfigureCINotif && !showConfigurePullRequestDecoNotif;
+ const showOnlyConfigurePR = showConfigurePullRequestDecoNotif && !showConfigureCINotif;
+ const showBoth = showConfigureCINotif && showConfigurePullRequestDecoNotif;
+ const isProjectAdmin = component.configuration?.showSettings;
+ const tutorialsLink = (
+ <Link
+ to={{
+ pathname: '/tutorials',
+ query: { id: component.key }
+ }}>
+ {translate('overview.project.next_steps.links.set_up_ci')}
+ </Link>
+ );
+ const projectSettingsLink = (
+ <Link
+ to={{
+ pathname: '/project/settings',
+ query: {
+ id: component.key,
+ category: PULL_REQUEST_DECORATION_BINDING_CATEGORY
+ }
+ }}>
+ {translate('overview.project.next_steps.links.project_settings')}
+ </Link>
+ );
+
+ return (
+ <DismissableAlert alertKey={`config_ci_pr_deco.${component.key}`} variant="info">
+ {showOnlyConfigureCI && (
+ <FormattedMessage
+ defaultMessage={translate('overview.project.next_steps.set_up_ci')}
+ id="overview.project.next_steps.set_up_ci"
+ values={{
+ link: tutorialsLink
+ }}
+ />
+ )}
+
+ {showOnlyConfigurePR &&
+ (isProjectAdmin ? (
+ <FormattedMessage
+ defaultMessage={translate('overview.project.next_steps.set_up_pr_deco.admin')}
+ id="overview.project.next_steps.set_up_pr_deco.admin"
+ values={{
+ link_project_settings: projectSettingsLink
+ }}
+ />
+ ) : (
+ translate('overview.project.next_steps.set_up_pr_deco')
+ ))}
+
+ {showBoth &&
+ (isProjectAdmin ? (
+ <FormattedMessage
+ defaultMessage={translate('overview.project.next_steps.set_up_pr_deco_and_ci.admin')}
+ id="overview.project.next_steps.set_up_pr_deco_and_ci.admin"
+ values={{
+ link_ci: tutorialsLink,
+ link_project_settings: projectSettingsLink
+ }}
+ />
+ ) : (
+ <FormattedMessage
+ defaultMessage={translate('overview.project.next_steps.set_up_pr_deco_and_ci')}
+ id="overview.project.next_steps.set_up_pr_deco_and_ci"
+ values={{ link_ci: tutorialsLink }}
+ />
+ ))}
+ </DismissableAlert>
+ );
+}
+
+export default withCurrentUser(FirstAnalysisNextStepsNotif);
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-test.tsx b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-test.tsx
index db78cc4ec3b..d7374852d03 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-test.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-test.tsx
@@ -32,11 +32,11 @@ import {
import { getAllTimeMachineData } from '../../../../api/time-machine';
import { getActivityGraph, saveActivityGraph } from '../../../../components/activity-graph/utils';
import { mockBranch, mockMainBranch } from '../../../../helpers/mocks/branch-like';
-import { mockComponent } from '../../../../helpers/testMocks';
+import { mockAnalysis, mockComponent } from '../../../../helpers/testMocks';
import { ComponentQualifier } from '../../../../types/component';
import { MetricKey } from '../../../../types/metrics';
import { GraphType } from '../../../../types/project-activity';
-import BranchOverview, { BRANCH_OVERVIEW_ACTIVITY_GRAPH } from '../BranchOverview';
+import BranchOverview, { BRANCH_OVERVIEW_ACTIVITY_GRAPH, NO_CI_DETECTED } from '../BranchOverview';
import BranchOverviewRenderer from '../BranchOverviewRenderer';
jest.mock('sonar-ui-common/helpers/dates', () => ({
@@ -146,7 +146,13 @@ jest.mock('../../../../api/projectActivity', () => {
const { mockAnalysis } = jest.requireActual('../../../../helpers/testMocks');
return {
getProjectActivity: jest.fn().mockResolvedValue({
- analyses: [mockAnalysis(), mockAnalysis(), mockAnalysis(), mockAnalysis(), mockAnalysis()]
+ analyses: [
+ mockAnalysis({ detectedCI: 'Cirrus CI' }),
+ mockAnalysis(),
+ mockAnalysis(),
+ mockAnalysis(),
+ mockAnalysis()
+ ]
})
};
});
@@ -345,6 +351,22 @@ it("should correctly load a component's history", async () => {
);
});
+it.each([
+ ['no analysis', [], undefined],
+ ['1 analysis, no CI data', [mockAnalysis()], false],
+ ['1 analysis, no CI detected', [mockAnalysis({ detectedCI: NO_CI_DETECTED })], false],
+ ['1 analysis, CI detected', [mockAnalysis({ detectedCI: 'Cirrus CI' })], true]
+])(
+ "should correctly flag a project that wasn't analyzed using a CI (%s)",
+ async (_, analyses, expected) => {
+ (getProjectActivity as jest.Mock).mockResolvedValueOnce({ analyses });
+
+ const wrapper = shallowRender();
+ await waitAndUpdate(wrapper);
+ expect(wrapper.state().detectedCIOnLastAnalysis).toBe(expected);
+ }
+);
+
it('should correctly handle graph type storage', () => {
const wrapper = shallowRender();
expect(getActivityGraph).toBeCalledWith(BRANCH_OVERVIEW_ACTIVITY_GRAPH, 'foo');
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverviewRenderer-test.tsx b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverviewRenderer-test.tsx
index 129ebb5091d..1f0b71a5617 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverviewRenderer-test.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverviewRenderer-test.tsx
@@ -25,9 +25,9 @@ import { GraphType } from '../../../../types/project-activity';
import { BranchOverviewRenderer, BranchOverviewRendererProps } from '../BranchOverviewRenderer';
it('should render correctly', () => {
- expect(shallowRender()).toMatchSnapshot();
- expect(shallowRender({ projectIsEmpty: true })).toMatchSnapshot();
- expect(shallowRender({ loadingHistory: true, loadingStatus: true })).toMatchSnapshot();
+ expect(shallowRender()).toMatchSnapshot('default');
+ expect(shallowRender({ projectIsEmpty: true })).toMatchSnapshot('empty project');
+ expect(shallowRender({ loadingHistory: true, loadingStatus: true })).toMatchSnapshot('loading');
});
function shallowRender(props: Partial<BranchOverviewRendererProps> = {}) {
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/FirstAnalysisNextStepsNotif-test.tsx b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/FirstAnalysisNextStepsNotif-test.tsx
new file mode 100644
index 00000000000..21494366a55
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/FirstAnalysisNextStepsNotif-test.tsx
@@ -0,0 +1,72 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockProjectAlmBindingResponse } from '../../../../helpers/mocks/alm-settings';
+import { mockComponent, mockCurrentUser, mockLoggedInUser } from '../../../../helpers/testMocks';
+import {
+ FirstAnalysisNextStepsNotif,
+ FirstAnalysisNextStepsNotifProps
+} from '../FirstAnalysisNextStepsNotif';
+
+it('should render correctly', () => {
+ expect(shallowRender({ currentUser: mockCurrentUser() }).type()).toBeNull();
+ expect(shallowRender({ detectedCIOnLastAnalysis: false })).toMatchSnapshot(
+ 'show prompt to configure CI'
+ );
+ expect(
+ shallowRender({
+ projectBinding: undefined
+ })
+ ).toMatchSnapshot('show prompt to configure PR decoration, regular user');
+ expect(
+ shallowRender({
+ component: mockComponent({ configuration: { showSettings: true } }),
+ projectBinding: undefined
+ })
+ ).toMatchSnapshot('show prompt to configure PR decoration, project admin');
+ expect(
+ shallowRender({
+ projectBinding: undefined,
+ detectedCIOnLastAnalysis: false
+ })
+ ).toMatchSnapshot('show prompt to configure PR decoration + CI, regular user');
+ expect(
+ shallowRender({
+ component: mockComponent({ configuration: { showSettings: true } }),
+ projectBinding: undefined,
+ detectedCIOnLastAnalysis: false
+ })
+ ).toMatchSnapshot('show prompt to configure PR decoration + CI, project admin');
+});
+
+function shallowRender(props: Partial<FirstAnalysisNextStepsNotifProps> = {}) {
+ return shallow<FirstAnalysisNextStepsNotifProps>(
+ <FirstAnalysisNextStepsNotif
+ component={mockComponent()}
+ branchesEnabled={true}
+ currentUser={mockLoggedInUser()}
+ detectedCIOnLastAnalysis={true}
+ projectBinding={mockProjectAlmBindingResponse()}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverview-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverview-test.tsx.snap
index 543ff2c4b99..bedeaafe142 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverview-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverview-test.tsx.snap
@@ -6,6 +6,7 @@ exports[`application overview should fetch correctly other branch 1`] = `
Array [
Object {
"date": "2017-03-01T09:36:01+0100",
+ "detectedCI": "Cirrus CI",
"events": Array [],
"key": "foo",
"projectVersion": "1.0",
@@ -94,6 +95,7 @@ exports[`application overview should fetch correctly other branch 1`] = `
"tags": Array [],
}
}
+ detectedCIOnLastAnalysis={true}
graph="coverage"
loadingHistory={false}
loadingStatus={false}
@@ -926,6 +928,7 @@ exports[`application overview should render correctly 1`] = `
Array [
Object {
"date": "2017-03-01T09:36:01+0100",
+ "detectedCI": "Cirrus CI",
"events": Array [],
"key": "foo",
"projectVersion": "1.0",
@@ -1014,6 +1017,7 @@ exports[`application overview should render correctly 1`] = `
"tags": Array [],
}
}
+ detectedCIOnLastAnalysis={true}
graph="coverage"
loadingHistory={false}
loadingStatus={false}
@@ -1846,6 +1850,7 @@ exports[`project overview should render correctly 1`] = `
Array [
Object {
"date": "2017-03-01T09:36:01+0100",
+ "detectedCI": "Cirrus CI",
"events": Array [],
"key": "foo",
"projectVersion": "1.0",
@@ -1927,6 +1932,7 @@ exports[`project overview should render correctly 1`] = `
"tags": Array [],
}
}
+ detectedCIOnLastAnalysis={true}
graph="coverage"
loadingHistory={false}
loadingStatus={false}
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverviewRenderer-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverviewRenderer-test.tsx.snap
index 551eb624b06..0ceef54903a 100644
--- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverviewRenderer-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverviewRenderer-test.tsx.snap
@@ -1,62 +1,47 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`should render correctly 1`] = `
-<div
- className="page page-limited"
->
+exports[`should render correctly: default 1`] = `
+<Fragment>
+ <Connect(withCurrentUser(FirstAnalysisNextStepsNotif))
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
<div
- className="overview"
+ className="page page-limited"
>
- <A11ySkipTarget
- anchor="overview_main"
- />
<div
- className="display-flex-row"
+ className="overview"
>
+ <A11ySkipTarget
+ anchor="overview_main"
+ />
<div
- className="width-25 big-spacer-right"
- >
- <Memo(QualityGatePanel)
- component={
- Object {
- "breadcrumbs": Array [],
- "key": "my-project",
- "name": "MyProject",
- "qualifier": "TRK",
- "qualityGate": Object {
- "isDefault": true,
- "key": "30",
- "name": "Sonar way",
- },
- "qualityProfiles": Array [
- Object {
- "deleted": false,
- "key": "my-qp",
- "language": "ts",
- "name": "Sonar way",
- },
- ],
- "tags": Array [],
- }
- }
- loading={false}
- />
- </div>
- <div
- className="flex-1"
+ className="display-flex-row"
>
<div
- className="display-flex-column"
+ className="width-25 big-spacer-right"
>
- <MeasuresPanel
- branch={
- Object {
- "analysisDate": "2018-01-01",
- "excludedFromPurge": true,
- "isMain": true,
- "name": "master",
- }
- }
+ <Memo(QualityGatePanel)
component={
Object {
"breadcrumbs": Array [],
@@ -80,194 +65,246 @@ exports[`should render correctly 1`] = `
}
}
loading={false}
- measures={
- Array [
+ />
+ </div>
+ <div
+ className="flex-1"
+ >
+ <div
+ className="display-flex-column"
+ >
+ <MeasuresPanel
+ branch={
Object {
- "bestValue": true,
- "leak": "1",
- "metric": Object {
- "id": "coverage",
- "key": "coverage",
- "name": "Coverage",
- "type": "PERCENT",
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
},
- "period": Object {
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ loading={false}
+ measures={
+ Array [
+ Object {
"bestValue": true,
- "index": 1,
+ "leak": "1",
+ "metric": Object {
+ "id": "coverage",
+ "key": "coverage",
+ "name": "Coverage",
+ "type": "PERCENT",
+ },
+ "period": Object {
+ "bestValue": true,
+ "index": 1,
+ "value": "1.0",
+ },
"value": "1.0",
},
- "value": "1.0",
- },
- ]
- }
- />
- <Memo(ActivityPanel)
- branchLike={
- Object {
- "analysisDate": "2018-01-01",
- "excludedFromPurge": true,
- "isMain": true,
- "name": "master",
+ ]
}
- }
- component={
- Object {
- "breadcrumbs": Array [],
- "key": "my-project",
- "name": "MyProject",
- "qualifier": "TRK",
- "qualityGate": Object {
- "isDefault": true,
- "key": "30",
- "name": "Sonar way",
- },
- "qualityProfiles": Array [
- Object {
- "deleted": false,
- "key": "my-qp",
- "language": "ts",
+ />
+ <Memo(ActivityPanel)
+ branchLike={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
"name": "Sonar way",
},
- ],
- "tags": Array [],
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
}
- }
- graph="issues"
- loading={false}
- measuresHistory={Array []}
- metrics={Array []}
- onGraphChange={[MockFunction]}
- />
+ graph="issues"
+ loading={false}
+ measuresHistory={Array []}
+ metrics={Array []}
+ onGraphChange={[MockFunction]}
+ />
+ </div>
</div>
</div>
</div>
</div>
-</div>
+</Fragment>
`;
-exports[`should render correctly 2`] = `
-<div
- className="page page-limited"
->
+exports[`should render correctly: empty project 1`] = `
+<Fragment>
+ <Connect(withCurrentUser(FirstAnalysisNextStepsNotif))
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
<div
- className="overview"
+ className="page page-limited"
>
- <A11ySkipTarget
- anchor="overview_main"
- />
- <Memo(NoCodeWarning)
- branchLike={
- Object {
- "analysisDate": "2018-01-01",
- "excludedFromPurge": true,
- "isMain": true,
- "name": "master",
+ <div
+ className="overview"
+ >
+ <A11ySkipTarget
+ anchor="overview_main"
+ />
+ <Memo(NoCodeWarning)
+ branchLike={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
}
- }
- component={
- Object {
- "breadcrumbs": Array [],
- "key": "my-project",
- "name": "MyProject",
- "qualifier": "TRK",
- "qualityGate": Object {
- "isDefault": true,
- "key": "30",
- "name": "Sonar way",
- },
- "qualityProfiles": Array [
- Object {
- "deleted": false,
- "key": "my-qp",
- "language": "ts",
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
"name": "Sonar way",
},
- ],
- "tags": Array [],
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
}
- }
- measures={
- Array [
- Object {
- "bestValue": true,
- "leak": "1",
- "metric": Object {
- "id": "coverage",
- "key": "coverage",
- "name": "Coverage",
- "type": "PERCENT",
- },
- "period": Object {
+ measures={
+ Array [
+ Object {
"bestValue": true,
- "index": 1,
+ "leak": "1",
+ "metric": Object {
+ "id": "coverage",
+ "key": "coverage",
+ "name": "Coverage",
+ "type": "PERCENT",
+ },
+ "period": Object {
+ "bestValue": true,
+ "index": 1,
+ "value": "1.0",
+ },
"value": "1.0",
},
- "value": "1.0",
- },
- ]
- }
- />
+ ]
+ }
+ />
+ </div>
</div>
-</div>
+</Fragment>
`;
-exports[`should render correctly 3`] = `
-<div
- className="page page-limited"
->
+exports[`should render correctly: loading 1`] = `
+<Fragment>
+ <Connect(withCurrentUser(FirstAnalysisNextStepsNotif))
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
+ },
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ />
<div
- className="overview"
+ className="page page-limited"
>
- <A11ySkipTarget
- anchor="overview_main"
- />
<div
- className="display-flex-row"
+ className="overview"
>
+ <A11ySkipTarget
+ anchor="overview_main"
+ />
<div
- className="width-25 big-spacer-right"
- >
- <Memo(QualityGatePanel)
- component={
- Object {
- "breadcrumbs": Array [],
- "key": "my-project",
- "name": "MyProject",
- "qualifier": "TRK",
- "qualityGate": Object {
- "isDefault": true,
- "key": "30",
- "name": "Sonar way",
- },
- "qualityProfiles": Array [
- Object {
- "deleted": false,
- "key": "my-qp",
- "language": "ts",
- "name": "Sonar way",
- },
- ],
- "tags": Array [],
- }
- }
- loading={true}
- />
- </div>
- <div
- className="flex-1"
+ className="display-flex-row"
>
<div
- className="display-flex-column"
+ className="width-25 big-spacer-right"
>
- <MeasuresPanel
- branch={
- Object {
- "analysisDate": "2018-01-01",
- "excludedFromPurge": true,
- "isMain": true,
- "name": "master",
- }
- }
+ <Memo(QualityGatePanel)
component={
Object {
"breadcrumbs": Array [],
@@ -291,67 +328,108 @@ exports[`should render correctly 3`] = `
}
}
loading={true}
- measures={
- Array [
+ />
+ </div>
+ <div
+ className="flex-1"
+ >
+ <div
+ className="display-flex-column"
+ >
+ <MeasuresPanel
+ branch={
Object {
- "bestValue": true,
- "leak": "1",
- "metric": Object {
- "id": "coverage",
- "key": "coverage",
- "name": "Coverage",
- "type": "PERCENT",
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
+ "name": "Sonar way",
},
- "period": Object {
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
+ }
+ loading={true}
+ measures={
+ Array [
+ Object {
"bestValue": true,
- "index": 1,
+ "leak": "1",
+ "metric": Object {
+ "id": "coverage",
+ "key": "coverage",
+ "name": "Coverage",
+ "type": "PERCENT",
+ },
+ "period": Object {
+ "bestValue": true,
+ "index": 1,
+ "value": "1.0",
+ },
"value": "1.0",
},
- "value": "1.0",
- },
- ]
- }
- />
- <Memo(ActivityPanel)
- branchLike={
- Object {
- "analysisDate": "2018-01-01",
- "excludedFromPurge": true,
- "isMain": true,
- "name": "master",
+ ]
}
- }
- component={
- Object {
- "breadcrumbs": Array [],
- "key": "my-project",
- "name": "MyProject",
- "qualifier": "TRK",
- "qualityGate": Object {
- "isDefault": true,
- "key": "30",
- "name": "Sonar way",
- },
- "qualityProfiles": Array [
- Object {
- "deleted": false,
- "key": "my-qp",
- "language": "ts",
+ />
+ <Memo(ActivityPanel)
+ branchLike={
+ Object {
+ "analysisDate": "2018-01-01",
+ "excludedFromPurge": true,
+ "isMain": true,
+ "name": "master",
+ }
+ }
+ component={
+ Object {
+ "breadcrumbs": Array [],
+ "key": "my-project",
+ "name": "MyProject",
+ "qualifier": "TRK",
+ "qualityGate": Object {
+ "isDefault": true,
+ "key": "30",
"name": "Sonar way",
},
- ],
- "tags": Array [],
+ "qualityProfiles": Array [
+ Object {
+ "deleted": false,
+ "key": "my-qp",
+ "language": "ts",
+ "name": "Sonar way",
+ },
+ ],
+ "tags": Array [],
+ }
}
- }
- graph="issues"
- loading={true}
- measuresHistory={Array []}
- metrics={Array []}
- onGraphChange={[MockFunction]}
- />
+ graph="issues"
+ loading={true}
+ measuresHistory={Array []}
+ metrics={Array []}
+ onGraphChange={[MockFunction]}
+ />
+ </div>
</div>
</div>
</div>
</div>
-</div>
+</Fragment>
`;
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/FirstAnalysisNextStepsNotif-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/FirstAnalysisNextStepsNotif-test.tsx.snap
new file mode 100644
index 00000000000..cb8c39ea0b6
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/FirstAnalysisNextStepsNotif-test.tsx.snap
@@ -0,0 +1,146 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: show prompt to configure CI 1`] = `
+<DismissableAlert
+ alertKey="config_ci_pr_deco.my-project"
+ variant="info"
+>
+ <FormattedMessage
+ defaultMessage="overview.project.next_steps.set_up_ci"
+ id="overview.project.next_steps.set_up_ci"
+ values={
+ Object {
+ "link": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/tutorials",
+ "query": Object {
+ "id": "my-project",
+ },
+ }
+ }
+ >
+ overview.project.next_steps.links.set_up_ci
+ </Link>,
+ }
+ }
+ />
+</DismissableAlert>
+`;
+
+exports[`should render correctly: show prompt to configure PR decoration + CI, project admin 1`] = `
+<DismissableAlert
+ alertKey="config_ci_pr_deco.my-project"
+ variant="info"
+>
+ <FormattedMessage
+ defaultMessage="overview.project.next_steps.set_up_pr_deco_and_ci.admin"
+ id="overview.project.next_steps.set_up_pr_deco_and_ci.admin"
+ values={
+ Object {
+ "link_ci": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/tutorials",
+ "query": Object {
+ "id": "my-project",
+ },
+ }
+ }
+ >
+ overview.project.next_steps.links.set_up_ci
+ </Link>,
+ "link_project_settings": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/project/settings",
+ "query": Object {
+ "category": "pull_request_decoration_binding",
+ "id": "my-project",
+ },
+ }
+ }
+ >
+ overview.project.next_steps.links.project_settings
+ </Link>,
+ }
+ }
+ />
+</DismissableAlert>
+`;
+
+exports[`should render correctly: show prompt to configure PR decoration + CI, regular user 1`] = `
+<DismissableAlert
+ alertKey="config_ci_pr_deco.my-project"
+ variant="info"
+>
+ <FormattedMessage
+ defaultMessage="overview.project.next_steps.set_up_pr_deco_and_ci"
+ id="overview.project.next_steps.set_up_pr_deco_and_ci"
+ values={
+ Object {
+ "link_ci": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/tutorials",
+ "query": Object {
+ "id": "my-project",
+ },
+ }
+ }
+ >
+ overview.project.next_steps.links.set_up_ci
+ </Link>,
+ }
+ }
+ />
+</DismissableAlert>
+`;
+
+exports[`should render correctly: show prompt to configure PR decoration, project admin 1`] = `
+<DismissableAlert
+ alertKey="config_ci_pr_deco.my-project"
+ variant="info"
+>
+ <FormattedMessage
+ defaultMessage="overview.project.next_steps.set_up_pr_deco.admin"
+ id="overview.project.next_steps.set_up_pr_deco.admin"
+ values={
+ Object {
+ "link_project_settings": <Link
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to={
+ Object {
+ "pathname": "/project/settings",
+ "query": Object {
+ "category": "pull_request_decoration_binding",
+ "id": "my-project",
+ },
+ }
+ }
+ >
+ overview.project.next_steps.links.project_settings
+ </Link>,
+ }
+ }
+ />
+</DismissableAlert>
+`;
+
+exports[`should render correctly: show prompt to configure PR decoration, regular user 1`] = `
+<DismissableAlert
+ alertKey="config_ci_pr_deco.my-project"
+ variant="info"
+>
+ overview.project.next_steps.set_up_pr_deco
+</DismissableAlert>
+`;
diff --git a/server/sonar-web/src/main/js/apps/overview/components/App.tsx b/server/sonar-web/src/main/js/apps/overview/components/App.tsx
index 9f1da8a7d94..cd1b6ff4a5a 100644
--- a/server/sonar-web/src/main/js/apps/overview/components/App.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/components/App.tsx
@@ -20,6 +20,7 @@
import * as React from 'react';
import { lazyLoadComponent } from 'sonar-ui-common/components/lazyLoadComponent';
import Suggestions from '../../../app/components/embed-docs-modal/Suggestions';
+import { withAppState } from '../../../components/hoc/withAppState';
import { Router, withRouter } from '../../../components/hoc/withRouter';
import { isPullRequest } from '../../../helpers/branch-like';
import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
@@ -31,6 +32,7 @@ const EmptyOverview = lazyLoadComponent(() => import('./EmptyOverview'));
const PullRequestOverview = lazyLoadComponent(() => import('../pullRequests/PullRequestOverview'));
interface Props {
+ appState: Pick<T.AppState, 'branchesEnabled'>;
branchLike?: BranchLike;
branchLikes: BranchLike[];
component: T.Component;
@@ -46,7 +48,13 @@ export class App extends React.PureComponent<Props> {
};
render() {
- const { branchLike, branchLikes, component, projectBinding } = this.props;
+ const {
+ appState: { branchesEnabled },
+ branchLike,
+ branchLikes,
+ component,
+ projectBinding
+ } = this.props;
if (this.isPortfolio()) {
return null;
@@ -61,7 +69,7 @@ export class App extends React.PureComponent<Props> {
<>
<Suggestions suggestions="overview" />
- {!component.analysisDate ? (
+ {!component.analysisDate && (
<EmptyOverview
branchLike={branchLike}
branchLikes={branchLikes}
@@ -69,12 +77,19 @@ export class App extends React.PureComponent<Props> {
hasAnalyses={this.props.isPending || this.props.isInProgress}
projectBinding={projectBinding}
/>
- ) : (
- <BranchOverview branch={branchLike} component={component} />
+ )}
+
+ {component.analysisDate && (
+ <BranchOverview
+ branch={branchLike}
+ branchesEnabled={branchesEnabled}
+ component={component}
+ projectBinding={projectBinding}
+ />
)}
</>
);
}
}
-export default withRouter(App);
+export default withRouter(withAppState(App));
diff --git a/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx
index c18e129fd84..bd742f4824e 100644
--- a/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx
+++ b/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx
@@ -20,6 +20,7 @@
import { shallow } from 'enzyme';
import * as React from 'react';
import { isSonarCloud } from '../../../../helpers/system';
+import { mockAppState } from '../../../../helpers/testMocks';
import BranchOverview from '../../branches/BranchOverview';
import { App } from '../App';
@@ -49,6 +50,12 @@ it('should render BranchOverview', () => {
function getWrapper(props = {}) {
return shallow(
- <App branchLikes={[]} component={component} router={{ replace: jest.fn() }} {...props} />
+ <App
+ appState={mockAppState()}
+ branchLikes={[]}
+ component={component}
+ router={{ replace: jest.fn() }}
+ {...props}
+ />
);
}
diff --git a/server/sonar-web/src/main/js/components/ui/DismissableAlert.css b/server/sonar-web/src/main/js/components/ui/DismissableAlert.css
new file mode 100644
index 00000000000..59d057e7e96
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/DismissableAlert.css
@@ -0,0 +1,29 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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.
+ */
+
+.dismissable-alert-banner .dismissable-alert-content {
+ max-width: var(--maxPageWidth);
+ min-width: var(--minPageWidth);
+}
+
+.dismissable-alert-banner .button-icon {
+ height: var(--tinyControlHeight);
+ width: var(--tinyControlHeight);
+}
diff --git a/server/sonar-web/src/main/js/components/ui/DismissableAlert.tsx b/server/sonar-web/src/main/js/components/ui/DismissableAlert.tsx
new file mode 100644
index 00000000000..9c3fa5f8dea
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/DismissableAlert.tsx
@@ -0,0 +1,69 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 classNames from 'classnames';
+import * as React from 'react';
+import { ButtonIcon } from 'sonar-ui-common/components/controls/buttons';
+import ClearIcon from 'sonar-ui-common/components/icons/ClearIcon';
+import { Alert, AlertProps } from 'sonar-ui-common/components/ui/Alert';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { get, save } from 'sonar-ui-common/helpers/storage';
+import './DismissableAlert.css';
+
+export interface DismissableAlertProps extends AlertProps {
+ alertKey: string;
+ children?: React.ReactNode;
+ className?: string;
+}
+
+export const DISMISSED_ALERT_STORAGE_KEY = 'sonarqube.dismissed_alert';
+
+export default function DismissableAlert(props: DismissableAlertProps) {
+ const { alertKey, children, className, display = 'banner', variant } = props;
+ const [show, setShow] = React.useState(false);
+
+ React.useEffect(() => {
+ if (get(DISMISSED_ALERT_STORAGE_KEY, alertKey) !== 'true') {
+ setShow(true);
+ }
+ }, [alertKey]);
+
+ const hideAlert = () => {
+ save(DISMISSED_ALERT_STORAGE_KEY, 'true', alertKey);
+ };
+
+ return !show ? null : (
+ <Alert
+ className={classNames(`dismissable-alert-${display}`, className)}
+ display={display}
+ variant={variant}>
+ <div className="display-flex-center dismissable-alert-content">
+ <div className="flex-1">{children}</div>
+ <ButtonIcon
+ aria-label={translate('alert.dismiss')}
+ onClick={() => {
+ hideAlert();
+ setShow(false);
+ }}>
+ <ClearIcon size={12} thin={true} />
+ </ButtonIcon>
+ </div>
+ </Alert>
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/DismissableAlert-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/DismissableAlert-test.tsx
new file mode 100644
index 00000000000..c371d5a0c19
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/DismissableAlert-test.tsx
@@ -0,0 +1,65 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2021 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { ButtonIcon } from 'sonar-ui-common/components/controls/buttons';
+import { save } from 'sonar-ui-common/helpers/storage';
+import { click } from 'sonar-ui-common/helpers/testUtils';
+import DismissableAlert, {
+ DismissableAlertProps,
+ DISMISSED_ALERT_STORAGE_KEY
+} from '../DismissableAlert';
+
+jest.mock('sonar-ui-common/helpers/storage', () => ({
+ get: jest.fn((_: string, suffix: string) => (suffix === 'bar' ? 'true' : undefined)),
+ save: jest.fn()
+}));
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(React, 'useEffect').mockImplementationOnce(f => f());
+});
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should render correctly with a non-default display', () => {
+ expect(shallowRender({ display: 'block' })).toMatchSnapshot();
+});
+
+it('should not render if it was dismissed', () => {
+ expect(shallowRender({ alertKey: 'bar' }).type()).toBeNull();
+});
+
+it('should correctly allow dismissing', () => {
+ const wrapper = shallowRender();
+ click(wrapper.find(ButtonIcon));
+ expect(save).toBeCalledWith(DISMISSED_ALERT_STORAGE_KEY, 'true', 'foo');
+});
+
+function shallowRender(props: Partial<DismissableAlertProps> = {}) {
+ return shallow<DismissableAlertProps>(
+ <DismissableAlert alertKey="foo" variant="info" {...props}>
+ <div>My content</div>
+ </DismissableAlert>
+ );
+}
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/DismissableAlert-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/DismissableAlert-test.tsx.snap
new file mode 100644
index 00000000000..7583076acd6
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/DismissableAlert-test.tsx.snap
@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Alert
+ className="dismissable-alert-banner"
+ display="banner"
+ variant="info"
+>
+ <div
+ className="display-flex-center dismissable-alert-content"
+ >
+ <div
+ className="flex-1"
+ >
+ <div>
+ My content
+ </div>
+ </div>
+ <ButtonIcon
+ aria-label="alert.dismiss"
+ onClick={[Function]}
+ >
+ <ClearIcon
+ size={12}
+ thin={true}
+ />
+ </ButtonIcon>
+ </div>
+</Alert>
+`;
+
+exports[`should render correctly with a non-default display 1`] = `
+<Alert
+ className="dismissable-alert-block"
+ display="block"
+ variant="info"
+>
+ <div
+ className="display-flex-center dismissable-alert-content"
+ >
+ <div
+ className="flex-1"
+ >
+ <div>
+ My content
+ </div>
+ </div>
+ <ButtonIcon
+ aria-label="alert.dismiss"
+ onClick={[Function]}
+ >
+ <ClearIcon
+ size={12}
+ thin={true}
+ />
+ </ButtonIcon>
+ </div>
+</Alert>
+`;
diff --git a/server/sonar-web/src/main/js/types/types.d.ts b/server/sonar-web/src/main/js/types/types.d.ts
index 404c070c28f..bb030129076 100644
--- a/server/sonar-web/src/main/js/types/types.d.ts
+++ b/server/sonar-web/src/main/js/types/types.d.ts
@@ -46,6 +46,7 @@ declare namespace T {
interface BaseAnalysis {
buildString?: string;
+ detectedCI?: string;
events: AnalysisEvent[];
key: string;
manualNewCodePeriodBaseline?: boolean;
diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
index 5e9f4e487c0..7d6cbeeb5b9 100644
--- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties
+++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties
@@ -1846,6 +1846,8 @@ alert.tooltip.warning=This is a warning message.
alert.tooltip.success=This is a success message.
alert.tooltip.info=This is an info message.
+alert.dismiss=Dismiss this message
+
#------------------------------------------------------------------------------
#
@@ -2966,6 +2968,13 @@ overview.project.branch_X_empty=The "{0}" branch of this project is empty.
overview.project.main_branch_no_lines_of_code=The main branch has no lines of code.
overview.project.main_branch_empty=The main branch of this project is empty.
overview.project.branch_needs_new_analysis=The branch data is incomplete. Run a new analysis to update it.
+overview.project.next_steps.set_up_pr_deco_and_ci.admin=To benefit from more of SonarQube's features, {link_ci} and set up DevOps platform integration in your {link_project_settings}.
+overview.project.next_steps.set_up_pr_deco_and_ci=To benefit from more of SonarQube's features, {link_ci} and ask a project administrator to set up DevOps platform integration.
+overview.project.next_steps.set_up_pr_deco.admin=To benefit from more of SonarQube's features, set up DevOps platform integration in your {link_project_settings}.
+overview.project.next_steps.set_up_pr_deco=To benefit from more of SonarQube's features, ask a project administrator to set up DevOps platform integration.
+overview.project.next_steps.set_up_ci=To benefit from more of SonarQube's features, {link}.
+overview.project.next_steps.links.project_settings=project settings
+overview.project.next_steps.links.set_up_ci=set up analysis in your favorite CI
overview.coverage_on=Coverage on
overview.coverage_on_X_lines=Coverage on {count} Lines to cover