]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14935 Prompt users for next steps once the first analysis is finished
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Fri, 11 Jun 2021 08:33:13 +0000 (10:33 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 17 Jun 2021 20:03:07 +0000 (20:03 +0000)
17 files changed:
server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx
server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx
server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-test.tsx
server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverviewRenderer-test.tsx
server/sonar-web/src/main/js/apps/overview/branches/__tests__/FirstAnalysisNextStepsNotif-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverview-test.tsx.snap
server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/BranchOverviewRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/FirstAnalysisNextStepsNotif-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/components/App.tsx
server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx
server/sonar-web/src/main/js/components/ui/DismissableAlert.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/DismissableAlert.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/__tests__/DismissableAlert-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/DismissableAlert-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/types/types.d.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 7ea3332157af2e497928fd93c919b5add510b56f..c1ccd55b86d197727694644a2755d09ac057e218 100644 (file)
@@ -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}
       />
index e4a40cc4253f50f9666e012f10b45f529c453cf4..52cf4428bbf81306905ad814815d83747da3044a 100644 (file)
 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 (file)
index 0000000..c345eba
--- /dev/null
@@ -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);
index db78cc4ec3bb6a30a6b0fe188b1ab6544b076906..d7374852d03df5c322f92a750879b2a11d9c3ce0 100644 (file)
@@ -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');
index 129ebb5091dab7cd69aabbbdb078718a60ddd88e..1f0b71a5617a84582e789767ceb3514df7bf4783 100644 (file)
@@ -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 (file)
index 0000000..2149436
--- /dev/null
@@ -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}
+    />
+  );
+}
index 543ff2c4b99b9833bc5df8adb66d305d1465e0b7..bedeaafe142169f61732fb919934721504f9bfbd 100644 (file)
@@ -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}
index 551eb624b06eb3c2d5792e7a7eb3d8740029b422..0ceef54903ae03ccb88fb9e2062a45bb6959d442 100644 (file)
@@ -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 (file)
index 0000000..cb8c39e
--- /dev/null
@@ -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>
+`;
index 9f1da8a7d942a7bc1cf4bdb815d3843656284d01..cd1b6ff4a5adeb9af0d104532e4dc5aeae63615e 100644 (file)
@@ -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));
index c18e129fd84d37a83b3c673a3fe99708a029ddc7..bd742f4824e74222db7f08982d1e441a34c6db77 100644 (file)
@@ -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 (file)
index 0000000..59d057e
--- /dev/null
@@ -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 (file)
index 0000000..9c3fa5f
--- /dev/null
@@ -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 (file)
index 0000000..c371d5a
--- /dev/null
@@ -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 (file)
index 0000000..7583076
--- /dev/null
@@ -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>
+`;
index 404c070c28fa178b269492b275129c79366b5615..bb03012907664a1f0b85a80b9a9aa0b2232ebc40 100644 (file)
@@ -46,6 +46,7 @@ declare namespace T {
 
   interface BaseAnalysis {
     buildString?: string;
+    detectedCI?: string;
     events: AnalysisEvent[];
     key: string;
     manualNewCodePeriodBaseline?: boolean;
index 5e9f4e487c06da98260593a14e12923f08894dd6..7d6cbeeb5b92b2ae692b946ba57d9303d29858ed 100644 (file)
@@ -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