]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14872 Display warning if PR deco cannot happen
authorWouter Admiraal <wouter.admiraal@sonarsource.com>
Mon, 31 May 2021 14:22:08 +0000 (16:22 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 10 Jun 2021 20:03:26 +0000 (20:03 +0000)
14 files changed:
server/sonar-web/src/main/js/app/components/ComponentContainer.tsx
server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx
server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavBgTaskNotif.tsx
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavProjectBindingErrorNotif.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavProjectBindingErrorNotif-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNav-test.tsx.snap
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavBgTaskNotif-test.tsx.snap
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavProjectBindingErrorNotif-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBinding-test.tsx
server/sonar-web/src/main/js/helpers/mocks/alm-settings.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index caf68e4eb0eca432e3c4555f3d2a0893910fa5ba..d1cf0ae7c1846aafff262ceb1c564b9940065368 100644 (file)
 import { differenceBy } from 'lodash';
 import * as React from 'react';
 import { connect } from 'react-redux';
-import { getProjectAlmBinding } from '../../api/alm-settings';
+import { HttpStatus } from 'sonar-ui-common/helpers/request';
+import { getProjectAlmBinding, validateProjectAlmBinding } from '../../api/alm-settings';
 import { getBranches, getPullRequests } from '../../api/branches';
 import { getAnalysisStatus, getTasksForComponent } from '../../api/ce';
 import { getComponentData } from '../../api/components';
 import { getComponentNavigation } from '../../api/nav';
+import { withAppState } from '../../components/hoc/withAppState';
 import { Location, Router, withRouter } from '../../components/hoc/withRouter';
 import {
   getBranchLikeQuery,
@@ -34,9 +36,12 @@ import {
 } from '../../helpers/branch-like';
 import { getPortfolioUrl } from '../../helpers/urls';
 import { registerBranchStatus, requireAuthorization } from '../../store/rootActions';
-import { ProjectAlmBindingResponse } from '../../types/alm-settings';
+import {
+  ProjectAlmBindingConfigurationErrors,
+  ProjectAlmBindingResponse
+} from '../../types/alm-settings';
 import { BranchLike } from '../../types/branch-like';
-import { isPortfolioLike } from '../../types/component';
+import { ComponentQualifier, isPortfolioLike } from '../../types/component';
 import { Task, TaskStatuses, TaskWarning } from '../../types/tasks';
 import ComponentContainerNotFound from './ComponentContainerNotFound';
 import { ComponentContext } from './ComponentContext';
@@ -44,6 +49,7 @@ import PageUnavailableDueToIndexation from './indexation/PageUnavailableDueToInd
 import ComponentNav from './nav/component/ComponentNav';
 
 interface Props {
+  appState: Pick<T.AppState, 'branchesEnabled'>;
   children: React.ReactElement;
   location: Pick<Location, 'query' | 'pathname'>;
   registerBranchStatus: (branchLike: BranchLike, component: string, status: T.Status) => void;
@@ -59,6 +65,7 @@ interface State {
   isPending: boolean;
   loading: boolean;
   projectBinding?: ProjectAlmBindingResponse;
+  projectBindingErrors?: ProjectAlmBindingConfigurationErrors;
   tasksInProgress?: Task[];
   warnings: TaskWarning[];
 }
@@ -90,96 +97,85 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
     window.clearTimeout(this.watchStatusTimer);
   }
 
-  addQualifier = (component: T.Component) => ({
-    ...component,
-    qualifier: component.breadcrumbs[component.breadcrumbs.length - 1].qualifier
-  });
-
-  fetchComponent() {
+  fetchComponent = async () => {
     const { branch, id: key, pullRequest } = this.props.location.query;
     this.setState({ loading: true });
 
-    const onError = (response?: Response) => {
+    let componentWithQualifier;
+    try {
+      const [nav, { component }] = await Promise.all([
+        getComponentNavigation({ component: key, branch, pullRequest }),
+        getComponentData({ component: key, branch, pullRequest })
+      ]);
+      componentWithQualifier = this.addQualifier({ ...nav, ...component });
+    } catch (e) {
       if (this.mounted) {
-        if (response && response.status === 403) {
+        if (e && e.status === HttpStatus.Forbidden) {
           this.props.requireAuthorization(this.props.router);
         } else {
           this.setState({ component: undefined, loading: false });
         }
       }
-    };
-
-    Promise.all([
-      getComponentNavigation({ component: key, branch, pullRequest }),
-      getComponentData({ component: key, branch, pullRequest }),
-      getProjectAlmBinding(key).catch(() => undefined)
-    ])
-      .then(([nav, { component }, projectBinding]) => {
-        const componentWithQualifier = this.addQualifier({ ...nav, ...component });
-
-        /*
-         * There used to be a redirect from /dashboard to /portfolio which caused issues.
-         * Links should be fixed to not rely on this redirect, but:
-         * This is a fail-safe in case there are still some faulty links remaining.
-         */
-        if (
-          this.props.location.pathname.match('dashboard') &&
-          isPortfolioLike(componentWithQualifier.qualifier)
-        ) {
-          this.props.router.replace(getPortfolioUrl(component.key));
-        }
+      return;
+    }
 
-        if (this.mounted) {
-          this.setState({ projectBinding });
-        }
+    /*
+     * There used to be a redirect from /dashboard to /portfolio which caused issues.
+     * Links should be fixed to not rely on this redirect, but:
+     * This is a fail-safe in case there are still some faulty links remaining.
+     */
+    if (
+      this.props.location.pathname.match('dashboard') &&
+      isPortfolioLike(componentWithQualifier.qualifier)
+    ) {
+      this.props.router.replace(getPortfolioUrl(componentWithQualifier.key));
+    }
 
-        return componentWithQualifier;
-      }, onError)
-      .then(this.fetchBranches)
-      .then(
-        ({ branchLike, branchLikes, component }) => {
-          if (this.mounted) {
-            this.setState({
-              branchLike,
-              branchLikes,
-              component,
-              loading: false
-            });
-            this.fetchStatus(component);
-            this.fetchWarnings(component, branchLike);
-          }
-        },
-        () => {}
-      );
-  }
+    const { branchLike, branchLikes } = await this.fetchBranches(componentWithQualifier);
+
+    const projectBinding = await getProjectAlmBinding(key).catch(() => undefined);
 
-  fetchBranches = (
-    component: T.Component
-  ): Promise<{
-    branchLike?: BranchLike;
-    branchLikes: BranchLike[];
-    component: T.Component;
-  }> => {
-    const breadcrumb = component.breadcrumbs.find(({ qualifier }) => {
-      return ['APP', 'TRK'].includes(qualifier);
+    if (this.mounted) {
+      this.setState({
+        branchLike,
+        branchLikes,
+        component: componentWithQualifier,
+        projectBinding,
+        loading: false
+      });
+
+      this.fetchStatus(componentWithQualifier);
+      this.fetchWarnings(componentWithQualifier, branchLike);
+      this.fetchProjectBindingErrors(componentWithQualifier);
+    }
+  };
+
+  fetchBranches = async (componentWithQualifier: T.Component) => {
+    const breadcrumb = componentWithQualifier.breadcrumbs.find(({ qualifier }) => {
+      return ([ComponentQualifier.Application, ComponentQualifier.Project] as string[]).includes(
+        qualifier
+      );
     });
 
+    let branchLike = undefined;
+    let branchLikes: BranchLike[] = [];
+
     if (breadcrumb) {
       const { key } = breadcrumb;
-      return Promise.all([
+      const [branches, pullRequests] = await Promise.all([
         getBranches(key),
-        breadcrumb.qualifier === 'APP' ? Promise.resolve([]) : getPullRequests(key)
-      ]).then(([branches, pullRequests]) => {
-        const branchLikes = [...branches, ...pullRequests];
-        const branchLike = this.getCurrentBranchLike(branchLikes);
+        breadcrumb.qualifier === ComponentQualifier.Application
+          ? Promise.resolve([])
+          : getPullRequests(key)
+      ]);
 
-        this.registerBranchStatuses(branchLikes, component);
+      branchLikes = [...branches, ...pullRequests];
+      branchLike = this.getCurrentBranchLike(branchLikes);
 
-        return { branchLike, branchLikes, component };
-      });
-    } else {
-      return Promise.resolve({ branchLikes: [], component });
+      this.registerBranchStatuses(branchLikes, componentWithQualifier);
     }
+
+    return { branchLike, branchLikes };
   };
 
   fetchStatus = (component: T.Component) => {
@@ -237,7 +233,7 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
   };
 
   fetchWarnings = (component: T.Component, branchLike?: BranchLike) => {
-    if (component.qualifier === 'TRK') {
+    if (component.qualifier === ComponentQualifier.Project) {
       getAnalysisStatus({
         component: component.key,
         ...getBranchLikeQuery(branchLike)
@@ -250,6 +246,22 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
     }
   };
 
+  fetchProjectBindingErrors = async (component: T.Component) => {
+    if (component.analysisDate === undefined && this.props.appState.branchesEnabled) {
+      const projectBindingErrors = await validateProjectAlmBinding(component.key).catch(
+        () => undefined
+      );
+      if (this.mounted) {
+        this.setState({ projectBindingErrors });
+      }
+    }
+  };
+
+  addQualifier = (component: T.Component) => ({
+    ...component,
+    qualifier: component.breadcrumbs[component.breadcrumbs.length - 1].qualifier
+  });
+
   getCurrentBranchLike = (branchLikes: BranchLike[]) => {
     const { query } = this.props.location;
     return query.pullRequest
@@ -347,27 +359,32 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
       currentTask,
       isPending,
       projectBinding,
+      projectBindingErrors,
       tasksInProgress
     } = this.state;
     const isInProgress = tasksInProgress && tasksInProgress.length > 0;
 
     return (
       <div>
-        {component && !['FIL', 'UTS'].includes(component.qualifier) && (
-          <ComponentNav
-            branchLikes={branchLikes}
-            component={component}
-            currentBranchLike={branchLike}
-            currentTask={currentTask}
-            currentTaskOnSameBranch={currentTask && this.isSameBranch(currentTask, branchLike)}
-            isInProgress={isInProgress}
-            isPending={isPending}
-            onComponentChange={this.handleComponentChange}
-            onWarningDismiss={this.handleWarningDismiss}
-            projectBinding={projectBinding}
-            warnings={this.state.warnings}
-          />
-        )}
+        {component &&
+          !([ComponentQualifier.File, ComponentQualifier.TestFile] as string[]).includes(
+            component.qualifier
+          ) && (
+            <ComponentNav
+              branchLikes={branchLikes}
+              component={component}
+              currentBranchLike={branchLike}
+              currentTask={currentTask}
+              currentTaskOnSameBranch={currentTask && this.isSameBranch(currentTask, branchLike)}
+              isInProgress={isInProgress}
+              isPending={isPending}
+              onComponentChange={this.handleComponentChange}
+              onWarningDismiss={this.handleWarningDismiss}
+              projectBinding={projectBinding}
+              projectBindingErrors={projectBindingErrors}
+              warnings={this.state.warnings}
+            />
+          )}
         {loading ? (
           <div className="page page-limited">
             <i className="spinner" />
@@ -393,4 +410,4 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
 
 const mapDispatchToProps = { registerBranchStatus, requireAuthorization };
 
-export default withRouter(connect(null, mapDispatchToProps)(ComponentContainer));
+export default withAppState(withRouter(connect(null, mapDispatchToProps)(ComponentContainer)));
index 570244785482e494293b33335fb75bbdc3238799..84fc4a8bb52bb2bfba4f14c7a75a382d53a32ef5 100644 (file)
 import { shallow } from 'enzyme';
 import * as React from 'react';
 import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
-import { getProjectAlmBinding } from '../../../api/alm-settings';
+import { getProjectAlmBinding, validateProjectAlmBinding } from '../../../api/alm-settings';
 import { getBranches, getPullRequests } from '../../../api/branches';
 import { getAnalysisStatus, getTasksForComponent } from '../../../api/ce';
 import { getComponentData } from '../../../api/components';
 import { getComponentNavigation } from '../../../api/nav';
+import { mockProjectAlmBindingConfigurationErrors } from '../../../helpers/mocks/alm-settings';
 import { mockBranch, mockMainBranch, mockPullRequest } from '../../../helpers/mocks/branch-like';
 import { mockTask } from '../../../helpers/mocks/tasks';
-import { mockComponent, mockLocation, mockRouter } from '../../../helpers/testMocks';
+import { mockAppState, mockComponent, mockLocation, mockRouter } from '../../../helpers/testMocks';
 import { AlmKeys } from '../../../types/alm-settings';
 import { ComponentQualifier } from '../../../types/component';
 import { TaskStatuses } from '../../../types/tasks';
@@ -68,7 +69,8 @@ jest.mock('../../../api/nav', () => ({
 }));
 
 jest.mock('../../../api/alm-settings', () => ({
-  getProjectAlmBinding: jest.fn().mockResolvedValue(undefined)
+  getProjectAlmBinding: jest.fn().mockResolvedValue(undefined),
+  validateProjectAlmBinding: jest.fn().mockResolvedValue(undefined)
 }));
 
 // mock this, because some of its children are using redux store
@@ -312,9 +314,36 @@ it('should correctly reload last task warnings if anything got dismissed', async
   expect(getAnalysisStatus).toBeCalledTimes(1);
 });
 
+describe('should correctly validate the project binding depending on the context', () => {
+  const COMPONENT = mockComponent({
+    breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: ComponentQualifier.Project }]
+  });
+  const PROJECT_BINDING_ERRORS = mockProjectAlmBindingConfigurationErrors();
+
+  it.each([
+    ["has an analysis; won't perform any check", { ...COMPONENT, analysisDate: '2020-01' }],
+    ['has a project binding; check is OK', COMPONENT, undefined, 1],
+    ['has a project binding; check is not OK', COMPONENT, PROJECT_BINDING_ERRORS, 1]
+  ])('%s', async (_, component, projectBindingErrors = undefined, n = 0) => {
+    (getComponentNavigation as jest.Mock).mockResolvedValueOnce({});
+    (getComponentData as jest.Mock<any>).mockResolvedValueOnce({ component });
+
+    if (n > 0) {
+      (validateProjectAlmBinding as jest.Mock).mockResolvedValueOnce(projectBindingErrors);
+    }
+
+    const wrapper = shallowRender({ appState: mockAppState({ branchesEnabled: true }) });
+    await waitAndUpdate(wrapper);
+    expect(wrapper.state().projectBindingErrors).toBe(projectBindingErrors);
+
+    expect(validateProjectAlmBinding).toBeCalledTimes(n);
+  });
+});
+
 function shallowRender(props: Partial<ComponentContainer['props']> = {}) {
   return shallow<ComponentContainer>(
     <ComponentContainer
+      appState={mockAppState()}
       location={mockLocation({ query: { id: 'foo' } })}
       registerBranchStatus={jest.fn()}
       requireAuthorization={jest.fn()}
index b0c0ab3c14de133bb0c7abfc8247fc05fd1dcb90..1e896e28f9ca4b51fb7a9dbd411a4d1f1e3576cc 100644 (file)
 import * as classNames from 'classnames';
 import * as React from 'react';
 import ContextNavBar from 'sonar-ui-common/components/ui/ContextNavBar';
-import { ProjectAlmBindingResponse } from '../../../../types/alm-settings';
+import {
+  ProjectAlmBindingConfigurationErrors,
+  ProjectAlmBindingResponse
+} from '../../../../types/alm-settings';
 import { BranchLike } from '../../../../types/branch-like';
 import { ComponentQualifier } from '../../../../types/component';
 import { Task, TaskStatuses, TaskWarning } from '../../../../types/tasks';
 import { rawSizes } from '../../../theme';
 import RecentHistory from '../../RecentHistory';
 import ComponentNavBgTaskNotif from './ComponentNavBgTaskNotif';
+import ComponentNavProjectBindingErrorNotif from './ComponentNavProjectBindingErrorNotif';
 import Header from './Header';
 import HeaderMeta from './HeaderMeta';
 import Menu from './Menu';
@@ -44,9 +48,12 @@ export interface ComponentNavProps {
   onComponentChange: (changes: Partial<T.Component>) => void;
   onWarningDismiss: () => void;
   projectBinding?: ProjectAlmBindingResponse;
+  projectBindingErrors?: ProjectAlmBindingConfigurationErrors;
   warnings: TaskWarning[];
 }
 
+const ALERT_HEIGHT = 30;
+
 export default function ComponentNav(props: ComponentNavProps) {
   const {
     branchLikes,
@@ -57,6 +64,7 @@ export default function ComponentNav(props: ComponentNavProps) {
     isInProgress,
     isPending,
     projectBinding,
+    projectBindingErrors,
     warnings
   } = props;
   const { contextNavHeightRaw, globalNavHeightRaw } = rawSizes;
@@ -78,9 +86,11 @@ export default function ComponentNav(props: ComponentNavProps) {
     }
   }, [component, component.key]);
 
-  let notifComponent;
+  let contextNavHeight = contextNavHeightRaw;
+
+  let bgTaskNotifComponent;
   if (isInProgress || isPending || (currentTask && currentTask.status === TaskStatuses.Failed)) {
-    notifComponent = (
+    bgTaskNotifComponent = (
       <ComponentNavBgTaskNotif
         component={component}
         currentTask={currentTask}
@@ -89,12 +99,31 @@ export default function ComponentNav(props: ComponentNavProps) {
         isPending={isPending}
       />
     );
+    contextNavHeight += ALERT_HEIGHT;
   }
 
-  const contextNavHeight = notifComponent ? contextNavHeightRaw + 30 : contextNavHeightRaw;
+  let prDecoNotifComponent;
+  if (projectBindingErrors !== undefined) {
+    prDecoNotifComponent = (
+      <ComponentNavProjectBindingErrorNotif
+        alm={projectBinding?.alm}
+        component={component}
+        projectBindingErrors={projectBindingErrors}
+      />
+    );
+    contextNavHeight += ALERT_HEIGHT;
+  }
 
   return (
-    <ContextNavBar height={contextNavHeight} id="context-navigation" notif={notifComponent}>
+    <ContextNavBar
+      height={contextNavHeight}
+      id="context-navigation"
+      notif={
+        <>
+          {bgTaskNotifComponent}
+          {prDecoNotifComponent}
+        </>
+      }>
       <div
         className={classNames('display-flex-center display-flex-space-between little-padded-top', {
           'padded-bottom': warnings.length === 0
index 0a87a66ba4fa67e847ed6049cedd5e4f3c5e8cc3..19424db709e7056d2123ee8baa42db1191c3a252 100644 (file)
@@ -111,7 +111,7 @@ export class ComponentNavBgTaskNotif extends React.PureComponent<Props> {
       }
 
       return (
-        <Alert display="banner" variant="error">
+        <Alert className="null-spacer-bottom" display="banner" variant="error">
           {message}
         </Alert>
       );
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavProjectBindingErrorNotif.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNavProjectBindingErrorNotif.tsx
new file mode 100644 (file)
index 0000000..1156c72
--- /dev/null
@@ -0,0 +1,96 @@
+/*
+ * 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 { Alert } from 'sonar-ui-common/components/ui/Alert';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import {
+  ALM_INTEGRATION,
+  PULL_REQUEST_DECORATION_BINDING_CATEGORY
+} from '../../../../apps/settings/components/AdditionalCategoryKeys';
+import { withCurrentUser } from '../../../../components/hoc/withCurrentUser';
+import { hasGlobalPermission } from '../../../../helpers/users';
+import {
+  AlmKeys,
+  ProjectAlmBindingConfigurationErrors,
+  ProjectAlmBindingConfigurationErrorScope
+} from '../../../../types/alm-settings';
+import { Permissions } from '../../../../types/permissions';
+
+export interface ComponentNavProjectBindingErrorNotifProps {
+  alm?: AlmKeys;
+  component: T.Component;
+  currentUser: T.CurrentUser;
+  projectBindingErrors: ProjectAlmBindingConfigurationErrors;
+}
+
+export function ComponentNavProjectBindingErrorNotif(
+  props: ComponentNavProjectBindingErrorNotifProps
+) {
+  const { alm, component, currentUser, projectBindingErrors } = props;
+  const isSysadmin = hasGlobalPermission(currentUser, Permissions.Admin);
+
+  let action;
+  if (projectBindingErrors.scope === ProjectAlmBindingConfigurationErrorScope.Global) {
+    if (isSysadmin) {
+      action = (
+        <Link
+          to={{
+            pathname: '/admin/settings',
+            query: {
+              category: ALM_INTEGRATION,
+              alm
+            }
+          }}>
+          {translate('component_navigation.pr_deco.action.check_global_settings')}
+        </Link>
+      );
+    } else {
+      action = translate('component_navigation.pr_deco.action.contact_sys_admin');
+    }
+  } else if (projectBindingErrors.scope === ProjectAlmBindingConfigurationErrorScope.Project) {
+    if (component.configuration?.showSettings) {
+      action = (
+        <Link
+          to={{
+            pathname: '/project/settings',
+            query: { category: PULL_REQUEST_DECORATION_BINDING_CATEGORY, id: component.key }
+          }}>
+          {translate('component_navigation.pr_deco.action.check_project_settings')}
+        </Link>
+      );
+    } else {
+      action = translate('component_navigation.pr_deco.action.contact_project_admin');
+    }
+  }
+
+  return (
+    <Alert display="banner" variant="warning">
+      <FormattedMessage
+        defaultMessage={translate('component_navigation.pr_deco.error_detected_X')}
+        id="component_navigation.pr_deco.error_detected_X"
+        values={{ action }}
+      />
+    </Alert>
+  );
+}
+
+export default withCurrentUser(ComponentNavProjectBindingErrorNotif);
index 7053107e433f16a4b1167cd7f10e9a1b689d84d2..47d8fbc25dcabd208ce3e62a3f16b20a2efff9c5 100644 (file)
@@ -19,6 +19,7 @@
  */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { mockProjectAlmBindingConfigurationErrors } from '../../../../../helpers/mocks/alm-settings';
 import { mockTask, mockTaskWarning } from '../../../../../helpers/mocks/tasks';
 import { mockComponent } from '../../../../../helpers/testMocks';
 import { ComponentQualifier } from '../../../../../types/component';
@@ -41,6 +42,11 @@ it('renders correctly', () => {
   expect(shallowRender({ currentTask: mockTask({ status: TaskStatuses.Failed }) })).toMatchSnapshot(
     'has failed notification'
   );
+  expect(
+    shallowRender({
+      projectBindingErrors: mockProjectAlmBindingConfigurationErrors()
+    })
+  ).toMatchSnapshot('has failed project binding');
 });
 
 it('correctly adds data to the history if there are breadcrumbs', () => {
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavProjectBindingErrorNotif-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNavProjectBindingErrorNotif-test.tsx
new file mode 100644 (file)
index 0000000..29a2feb
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * 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 { mockProjectAlmBindingConfigurationErrors } from '../../../../../helpers/mocks/alm-settings';
+import { mockComponent, mockCurrentUser, mockLoggedInUser } from '../../../../../helpers/testMocks';
+import {
+  AlmKeys,
+  ProjectAlmBindingConfigurationErrorScope
+} from '../../../../../types/alm-settings';
+import { Permissions } from '../../../../../types/permissions';
+import {
+  ComponentNavProjectBindingErrorNotif,
+  ComponentNavProjectBindingErrorNotifProps
+} from '../ComponentNavProjectBindingErrorNotif';
+
+it('should render correctly', () => {
+  expect(shallowRender()).toMatchSnapshot('global, no admin');
+  expect(
+    shallowRender({
+      currentUser: mockLoggedInUser({ permissions: { global: [Permissions.Admin] } })
+    })
+  ).toMatchSnapshot('global, admin');
+  expect(
+    shallowRender({
+      projectBindingErrors: mockProjectAlmBindingConfigurationErrors({
+        scope: ProjectAlmBindingConfigurationErrorScope.Project
+      })
+    })
+  ).toMatchSnapshot('project, no admin');
+  expect(
+    shallowRender({
+      component: mockComponent({ configuration: { showSettings: true } }),
+      projectBindingErrors: mockProjectAlmBindingConfigurationErrors({
+        scope: ProjectAlmBindingConfigurationErrorScope.Project
+      })
+    })
+  ).toMatchSnapshot('project, admin');
+  expect(
+    shallowRender({
+      projectBindingErrors: mockProjectAlmBindingConfigurationErrors({
+        scope: ProjectAlmBindingConfigurationErrorScope.Unknown
+      })
+    })
+  ).toMatchSnapshot('unknown');
+});
+
+function shallowRender(props: Partial<ComponentNavProjectBindingErrorNotifProps> = {}) {
+  return shallow<ComponentNavProjectBindingErrorNotifProps>(
+    <ComponentNavProjectBindingErrorNotif
+      alm={AlmKeys.GitHub}
+      component={mockComponent()}
+      currentUser={mockCurrentUser()}
+      projectBindingErrors={mockProjectAlmBindingConfigurationErrors()}
+      {...props}
+    />
+  );
+}
index a5669ebe18d47dd26f5d9f3ccc31606293377403..ca190a5ec8c95607db6616c169e866191efcfff7 100644 (file)
@@ -4,6 +4,7 @@ exports[`renders correctly: default 1`] = `
 <ContextNavBar
   height={72}
   id="context-navigation"
+  notif={<React.Fragment />}
 >
   <div
     className="display-flex-center display-flex-space-between little-padded-top padded-bottom"
@@ -151,7 +152,59 @@ exports[`renders correctly: has failed notification 1`] = `
   height={102}
   id="context-navigation"
   notif={
-    <withRouter(ComponentNavBgTaskNotif)
+    <React.Fragment>
+      <withRouter(ComponentNavBgTaskNotif)
+        component={
+          Object {
+            "breadcrumbs": Array [
+              Object {
+                "key": "foo",
+                "name": "Foo",
+                "qualifier": "TRK",
+              },
+            ],
+            "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 [],
+          }
+        }
+        currentTask={
+          Object {
+            "analysisId": "x123",
+            "componentKey": "foo",
+            "componentName": "Foo",
+            "componentQualifier": "TRK",
+            "id": "AXR8jg_0mF2ZsYr8Wzs2",
+            "status": "FAILED",
+            "submittedAt": "2020-09-11T11:45:35+0200",
+            "type": "REPORT",
+          }
+        }
+        isInProgress={false}
+        isPending={false}
+      />
+    </React.Fragment>
+  }
+>
+  <div
+    className="display-flex-center display-flex-space-between little-padded-top padded-bottom"
+  >
+    <Connect(Component)
+      branchLikes={Array []}
       component={
         Object {
           "breadcrumbs": Array [
@@ -180,21 +233,164 @@ exports[`renders correctly: has failed notification 1`] = `
           "tags": Array [],
         }
       }
-      currentTask={
+    />
+    <Connect(HeaderMeta)
+      component={
         Object {
-          "analysisId": "x123",
-          "componentKey": "foo",
-          "componentName": "Foo",
-          "componentQualifier": "TRK",
-          "id": "AXR8jg_0mF2ZsYr8Wzs2",
-          "status": "FAILED",
-          "submittedAt": "2020-09-11T11:45:35+0200",
-          "type": "REPORT",
+          "breadcrumbs": Array [
+            Object {
+              "key": "foo",
+              "name": "Foo",
+              "qualifier": "TRK",
+            },
+          ],
+          "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 [],
         }
       }
-      isInProgress={false}
-      isPending={false}
+      onWarningDismiss={[MockFunction]}
+      warnings={Array []}
     />
+  </div>
+  <Connect(withAppState(Menu))
+    branchLikes={Array []}
+    component={
+      Object {
+        "breadcrumbs": Array [
+          Object {
+            "key": "foo",
+            "name": "Foo",
+            "qualifier": "TRK",
+          },
+        ],
+        "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 [],
+      }
+    }
+    isInProgress={false}
+    isPending={false}
+    onToggleProjectInfo={[Function]}
+  />
+  <InfoDrawer
+    displayed={false}
+    onClose={[Function]}
+    top={120}
+  >
+    <Connect(ProjectInformation)
+      component={
+        Object {
+          "breadcrumbs": Array [
+            Object {
+              "key": "foo",
+              "name": "Foo",
+              "qualifier": "TRK",
+            },
+          ],
+          "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 [],
+        }
+      }
+      onComponentChange={[MockFunction]}
+    />
+  </InfoDrawer>
+</ContextNavBar>
+`;
+
+exports[`renders correctly: has failed project binding 1`] = `
+<ContextNavBar
+  height={102}
+  id="context-navigation"
+  notif={
+    <React.Fragment>
+      <Connect(withCurrentUser(ComponentNavProjectBindingErrorNotif))
+        component={
+          Object {
+            "breadcrumbs": Array [
+              Object {
+                "key": "foo",
+                "name": "Foo",
+                "qualifier": "TRK",
+              },
+            ],
+            "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 [],
+          }
+        }
+        projectBindingErrors={
+          Object {
+            "errors": Array [
+              Object {
+                "msg": "Foo bar is not correct",
+              },
+              Object {
+                "msg": "Bar baz has no permissions here",
+              },
+            ],
+            "scope": "GLOBAL",
+          }
+        }
+      />
+    </React.Fragment>
   }
 >
   <div
@@ -343,38 +539,40 @@ exports[`renders correctly: has in progress notification 1`] = `
   height={102}
   id="context-navigation"
   notif={
-    <withRouter(ComponentNavBgTaskNotif)
-      component={
-        Object {
-          "breadcrumbs": Array [
-            Object {
-              "key": "foo",
-              "name": "Foo",
-              "qualifier": "TRK",
-            },
-          ],
-          "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",
+    <React.Fragment>
+      <withRouter(ComponentNavBgTaskNotif)
+        component={
+          Object {
+            "breadcrumbs": Array [
+              Object {
+                "key": "foo",
+                "name": "Foo",
+                "qualifier": "TRK",
+              },
+            ],
+            "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 [],
+          }
         }
-      }
-      isInProgress={true}
-      isPending={false}
-    />
+        isInProgress={true}
+        isPending={false}
+      />
+    </React.Fragment>
   }
 >
   <div
@@ -523,38 +721,40 @@ exports[`renders correctly: has pending notification 1`] = `
   height={102}
   id="context-navigation"
   notif={
-    <withRouter(ComponentNavBgTaskNotif)
-      component={
-        Object {
-          "breadcrumbs": Array [
-            Object {
-              "key": "foo",
-              "name": "Foo",
-              "qualifier": "TRK",
-            },
-          ],
-          "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",
+    <React.Fragment>
+      <withRouter(ComponentNavBgTaskNotif)
+        component={
+          Object {
+            "breadcrumbs": Array [
+              Object {
+                "key": "foo",
+                "name": "Foo",
+                "qualifier": "TRK",
+              },
+            ],
+            "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 [],
+          }
         }
-      }
-      isInProgress={false}
-      isPending={true}
-    />
+        isInProgress={false}
+        isPending={true}
+      />
+    </React.Fragment>
   }
 >
   <div
@@ -702,6 +902,7 @@ exports[`renders correctly: has warnings 1`] = `
 <ContextNavBar
   height={72}
   id="context-navigation"
+  notif={<React.Fragment />}
 >
   <div
     className="display-flex-center display-flex-space-between little-padded-top"
diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavProjectBindingErrorNotif-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavProjectBindingErrorNotif-test.tsx.snap
new file mode 100644 (file)
index 0000000..cbf6012
--- /dev/null
@@ -0,0 +1,114 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: global, admin 1`] = `
+<Alert
+  display="banner"
+  variant="warning"
+>
+  <FormattedMessage
+    defaultMessage="component_navigation.pr_deco.error_detected_X"
+    id="component_navigation.pr_deco.error_detected_X"
+    values={
+      Object {
+        "action": <Link
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/admin/settings",
+              "query": Object {
+                "alm": "github",
+                "category": "almintegration",
+              },
+            }
+          }
+        >
+          component_navigation.pr_deco.action.check_global_settings
+        </Link>,
+      }
+    }
+  />
+</Alert>
+`;
+
+exports[`should render correctly: global, no admin 1`] = `
+<Alert
+  display="banner"
+  variant="warning"
+>
+  <FormattedMessage
+    defaultMessage="component_navigation.pr_deco.error_detected_X"
+    id="component_navigation.pr_deco.error_detected_X"
+    values={
+      Object {
+        "action": "component_navigation.pr_deco.action.contact_sys_admin",
+      }
+    }
+  />
+</Alert>
+`;
+
+exports[`should render correctly: project, admin 1`] = `
+<Alert
+  display="banner"
+  variant="warning"
+>
+  <FormattedMessage
+    defaultMessage="component_navigation.pr_deco.error_detected_X"
+    id="component_navigation.pr_deco.error_detected_X"
+    values={
+      Object {
+        "action": <Link
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/project/settings",
+              "query": Object {
+                "category": "pull_request_decoration_binding",
+                "id": "my-project",
+              },
+            }
+          }
+        >
+          component_navigation.pr_deco.action.check_project_settings
+        </Link>,
+      }
+    }
+  />
+</Alert>
+`;
+
+exports[`should render correctly: project, no admin 1`] = `
+<Alert
+  display="banner"
+  variant="warning"
+>
+  <FormattedMessage
+    defaultMessage="component_navigation.pr_deco.error_detected_X"
+    id="component_navigation.pr_deco.error_detected_X"
+    values={
+      Object {
+        "action": "component_navigation.pr_deco.action.contact_project_admin",
+      }
+    }
+  />
+</Alert>
+`;
+
+exports[`should render correctly: unknown 1`] = `
+<Alert
+  display="banner"
+  variant="warning"
+>
+  <FormattedMessage
+    defaultMessage="component_navigation.pr_deco.error_detected_X"
+    id="component_navigation.pr_deco.error_detected_X"
+    values={
+      Object {
+        "action": undefined,
+      }
+    }
+  />
+</Alert>
+`;
index ae61dfde85c7b30dee5381639ba2e2a9ea56ca10..3e5adbeecdf56b75b23cbf6dc190f47674bfb99c 100644 (file)
@@ -69,6 +69,10 @@ th.hide-overflow {
   margin-top: 0 !important;
 }
 
+.null-spacer-bottom {
+  margin-bottom: 0 !important;
+}
+
 .spacer {
   margin: 8px !important;
 }
index 48f82838adf9fb5cf67476b6889e8f262fdc390a..f20850762b657bdea0c6bc8646c9b4cdabdea104 100644 (file)
@@ -33,15 +33,11 @@ import {
 } from '../../../../../api/alm-settings';
 import {
   mockAlmSettingsInstance,
+  mockProjectAlmBindingConfigurationErrors,
   mockProjectAlmBindingResponse
 } from '../../../../../helpers/mocks/alm-settings';
 import { mockComponent, mockCurrentUser } from '../../../../../helpers/testMocks';
-import {
-  AlmKeys,
-  AlmSettingsInstance,
-  ProjectAlmBindingConfigurationErrors,
-  ProjectAlmBindingConfigurationErrorScope
-} from '../../../../../types/alm-settings';
+import { AlmKeys, AlmSettingsInstance } from '../../../../../types/alm-settings';
 import { PRDecorationBinding } from '../PRDecorationBinding';
 import PRDecorationBindingRenderer from '../PRDecorationBindingRenderer';
 
@@ -373,10 +369,7 @@ it('should call the validation WS and store errors', async () => {
     mockProjectAlmBindingResponse({ key: 'key' })
   );
 
-  const errors: ProjectAlmBindingConfigurationErrors = {
-    scope: ProjectAlmBindingConfigurationErrorScope.Global,
-    errors: [{ msg: 'Test' }, { msg: 'tesT' }]
-  };
+  const errors = mockProjectAlmBindingConfigurationErrors();
   (validateProjectAlmBinding as jest.Mock).mockRejectedValueOnce(errors);
 
   const wrapper = shallowRender();
index a5cb9c7868b39586eb5514d744a04bc3ee5d0488..0abd3e4ef53b50283fb886e14b2513301957d4b7 100644 (file)
@@ -27,6 +27,8 @@ import {
   BitbucketCloudBindingDefinition,
   GithubBindingDefinition,
   GitlabBindingDefinition,
+  ProjectAlmBindingConfigurationErrors,
+  ProjectAlmBindingConfigurationErrorScope,
   ProjectAlmBindingResponse,
   ProjectAzureBindingResponse,
   ProjectBitbucketBindingResponse,
@@ -198,3 +200,13 @@ export function mockAlmSettingsBindingStatus(
     ...overrides
   };
 }
+
+export function mockProjectAlmBindingConfigurationErrors(
+  overrides: Partial<ProjectAlmBindingConfigurationErrors> = {}
+): ProjectAlmBindingConfigurationErrors {
+  return {
+    scope: ProjectAlmBindingConfigurationErrorScope.Global,
+    errors: [{ msg: 'Foo bar is not correct' }, { msg: 'Bar baz has no permissions here' }],
+    ...overrides
+  };
+}
index 85149fff2d75dbba2c7306fe39928d013dd395d7..bd9bcaff9a74cd59263cd1d0669884c9083f1264 100644 (file)
@@ -2802,6 +2802,11 @@ component_navigation.status.last_blocked_due_to_bad_license_X=Last analysis bloc
 component_navigation.last_analysis_had_warnings=Last analysis had {warnings}
 component_navigation.x_warnings={warningsCount} {warningsCount, plural, one {warning} other {warnings}}
 
+component_navigation.pr_deco.error_detected_X=We've detected an issue with your configuration. Your SonarQube instance won't be able to perform any pull request decoration. {action}
+component_navigation.pr_deco.action.check_global_settings=Please check your global settings.
+component_navigation.pr_deco.action.contact_sys_admin=Please contact your system administrator.
+component_navigation.pr_deco.action.check_project_settings=Please check your project settings.
+component_navigation.pr_deco.action.contact_project_admin=Please contact your project administrator.
 
 background_task.status.ALL=All
 background_task.status.PENDING=Pending