]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19932 Have always actual projectBinding due to react-query
authorViktor Vorona <viktor.vorona@sonarsource.com>
Thu, 24 Aug 2023 08:25:22 +0000 (10:25 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 25 Aug 2023 20:02:41 +0000 (20:02 +0000)
35 files changed:
server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts
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/Header.tsx
server/sonar-web/src/main/js/app/components/nav/component/__tests__/Header-test.tsx
server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchHelpTooltip.tsx
server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx
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
server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx
server/sonar-web/src/main/js/apps/overview/components/App.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx
server/sonar-web/src/main/js/apps/permissions/project/components/__tests__/PermissionsProject-it.tsx
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBinding.tsx
server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBinding-it.tsx
server/sonar-web/src/main/js/apps/tutorials/components/TutorialsApp.tsx
server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx
server/sonar-web/src/main/js/components/permissions/HoldersList.tsx
server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx
server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx
server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx
server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/BitbucketPipelinesTutorial.tsx
server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/RepositoryVariables.tsx
server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/__tests__/BitbucketPipelinesTutorial-it.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx
server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/GithubActionTutorial-it.tsx
server/sonar-web/src/main/js/components/tutorials/jenkins/JenkinsTutorial.tsx
server/sonar-web/src/main/js/components/tutorials/jenkins/MultiBranchPipelineStep.tsx
server/sonar-web/src/main/js/components/tutorials/jenkins/__tests__/JenkinsTutorial-it.tsx
server/sonar-web/src/main/js/queries/devops-integration.ts [new file with mode: 0644]
server/sonar-web/src/main/js/types/component.ts

index 0291697e4eb08b5c94d62609fa5bb47d0c1667df..01c4735096e5520319722dcf75fd09c4738a81b3 100644 (file)
@@ -48,6 +48,7 @@ import {
   deleteProjectAlmBinding,
   getAlmDefinitions,
   getAlmSettings,
+  getAlmSettingsNoCatch,
   getProjectAlmBinding,
   setProjectAzureBinding,
   setProjectBitbucketBinding,
@@ -109,6 +110,8 @@ interface EnhancedProjectAlmBindingParam extends ProjectAlmBindingParams {
   summaryCommentEnabled?: boolean;
 }
 
+jest.mock('../alm-settings');
+
 export default class AlmSettingsServiceMock {
   #almDefinitions: AlmSettingsBindingDefinitions;
   #almSettings: AlmSettingsInstance[];
@@ -120,6 +123,7 @@ export default class AlmSettingsServiceMock {
     this.#almSettings = cloneDeep(defaultAlmSettings);
     this.#almDefinitions = cloneDeep(defaultAlmDefinitions);
     jest.mocked(getAlmSettings).mockImplementation(this.handleGetAlmSettings);
+    jest.mocked(getAlmSettingsNoCatch).mockImplementation(this.handleGetAlmSettings);
     jest.mocked(getAlmDefinitions).mockImplementation(this.handleGetAlmDefinitions);
     jest.mocked(countBoundProjects).mockImplementation(this.handleCountBoundProjects);
     jest.mocked(validateAlmSettings).mockImplementation(this.handleValidateAlmSettings);
index 2fde9f19ab0ba2bd5837285419ebc419ac6f489c..bba0da8a47168ebb41dacbf3f81fec025c062139 100644 (file)
@@ -22,7 +22,7 @@ import { differenceBy } from 'lodash';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import { Outlet } from 'react-router-dom';
-import { getProjectAlmBinding, validateProjectAlmBinding } from '../../api/alm-settings';
+import { validateProjectAlmBinding } from '../../api/alm-settings';
 import { getTasksForComponent } from '../../api/ce';
 import { getComponentData } from '../../api/components';
 import { getComponentNavigation } from '../../api/navigation';
@@ -30,10 +30,7 @@ import { Location, Router, withRouter } from '../../components/hoc/withRouter';
 import { translateWithParameters } from '../../helpers/l10n';
 import { HttpStatus } from '../../helpers/request';
 import { getPortfolioUrl, getProjectUrl } from '../../helpers/urls';
-import {
-  ProjectAlmBindingConfigurationErrors,
-  ProjectAlmBindingResponse,
-} from '../../types/alm-settings';
+import { ProjectAlmBindingConfigurationErrors } from '../../types/alm-settings';
 import { ComponentQualifier, isPortfolioLike } from '../../types/component';
 import { Feature } from '../../types/features';
 import { Task, TaskStatuses, TaskTypes } from '../../types/tasks';
@@ -56,7 +53,6 @@ interface State {
   currentTask?: Task;
   isPending: boolean;
   loading: boolean;
-  projectBinding?: ProjectAlmBindingResponse;
   projectBindingErrors?: ProjectAlmBindingConfigurationErrors;
   tasksInProgress?: Task[];
 }
@@ -124,17 +120,10 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
       this.props.router.replace(getPortfolioUrl(componentWithQualifier.key));
     }
 
-    let projectBinding;
-
-    if (componentWithQualifier.qualifier === ComponentQualifier.Project) {
-      projectBinding = await getProjectAlmBinding(key).catch(() => undefined);
-    }
-
     if (this.mounted) {
       this.setState(
         {
           component: componentWithQualifier,
-          projectBinding,
           loading: false,
         },
         () => {
@@ -329,8 +318,7 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
       return <ComponentContainerNotFound />;
     }
 
-    const { currentTask, isPending, projectBinding, projectBindingErrors, tasksInProgress } =
-      this.state;
+    const { currentTask, isPending, projectBindingErrors, tasksInProgress } = this.state;
 
     const isInProgress = tasksInProgress && tasksInProgress.length > 0;
 
@@ -353,7 +341,6 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
               currentTask={currentTask}
               isInProgress={isInProgress}
               isPending={isPending}
-              projectBinding={projectBinding}
               projectBindingErrors={projectBindingErrors}
             />
           )}
@@ -370,7 +357,6 @@ export class ComponentContainer extends React.PureComponent<Props, State> {
               isPending,
               onComponentChange: this.handleComponentChange,
               fetchComponent: this.fetchComponent,
-              projectBinding,
             }}
           >
             <Outlet />
index 48bf7483773702a9788785dbd29fe030c9246deb..6b53e8980df8cd737cc7be24320751f14225a89b 100644 (file)
@@ -32,7 +32,6 @@ import { mockTask } from '../../../helpers/mocks/tasks';
 import { HttpStatus } from '../../../helpers/request';
 import { mockLocation, mockRouter } from '../../../helpers/testMocks';
 import { waitAndUpdate } from '../../../helpers/testUtils';
-import { AlmKeys } from '../../../types/alm-settings';
 import { ComponentQualifier, Visibility } from '../../../types/component';
 import { TaskStatuses, TaskTypes } from '../../../types/tasks';
 import { Component } from '../../../types/types';
@@ -115,40 +114,6 @@ it('changes component', () => {
   });
 });
 
-it('loads the project binding, if any', async () => {
-  const component = mockComponent({
-    breadcrumbs: [{ key: 'foo', name: 'foo', qualifier: ComponentQualifier.Project }],
-  });
-
-  jest
-    .mocked(getComponentNavigation)
-    .mockResolvedValueOnce({} as unknown as Awaited<ReturnType<typeof getComponentNavigation>>);
-
-  jest
-    .mocked(getComponentData)
-    .mockResolvedValueOnce({ component } as unknown as Awaited<ReturnType<typeof getComponentData>>)
-    .mockResolvedValueOnce({ component } as unknown as Awaited<
-      ReturnType<typeof getComponentData>
-    >);
-
-  jest
-    .mocked(getProjectAlmBinding)
-    .mockResolvedValueOnce(undefined as unknown as Awaited<ReturnType<typeof getProjectAlmBinding>>)
-    .mockResolvedValueOnce({
-      alm: AlmKeys.GitHub,
-      key: 'foo',
-    } as unknown as Awaited<ReturnType<typeof getProjectAlmBinding>>);
-
-  const wrapper = shallowRender();
-  await waitAndUpdate(wrapper);
-  expect(getProjectAlmBinding).toHaveBeenCalled();
-  expect(wrapper.state().projectBinding).toBeUndefined();
-
-  wrapper.setProps({ location: mockLocation({ query: { id: 'bar' } }) });
-  await waitAndUpdate(wrapper);
-  expect(wrapper.state().projectBinding).toEqual({ alm: AlmKeys.GitHub, key: 'foo' });
-});
-
 it("doesn't load branches portfolio", async () => {
   const wrapper = shallowRender({ location: mockLocation({ query: { id: 'portfolioKey' } }) });
   await waitAndUpdate(wrapper);
index cf8019d236adb2245bd0834e9a1553458b9b9e6c..e25d86d2a97e1f13874272fa6f885f29a80ca07b 100644 (file)
@@ -22,10 +22,7 @@ import * as React from 'react';
 import ScreenPositionHelper from '../../../../components/common/ScreenPositionHelper';
 import NCDAutoUpdateMessage from '../../../../components/new-code-definition/NCDAutoUpdateMessage';
 import { translate } from '../../../../helpers/l10n';
-import {
-  ProjectAlmBindingConfigurationErrors,
-  ProjectAlmBindingResponse,
-} from '../../../../types/alm-settings';
+import { ProjectAlmBindingConfigurationErrors } from '../../../../types/alm-settings';
 import { ComponentQualifier } from '../../../../types/component';
 import { Task } from '../../../../types/tasks';
 import { Component } from '../../../../types/types';
@@ -40,13 +37,11 @@ export interface ComponentNavProps {
   currentTask?: Task;
   isInProgress?: boolean;
   isPending?: boolean;
-  projectBinding?: ProjectAlmBindingResponse;
   projectBindingErrors?: ProjectAlmBindingConfigurationErrors;
 }
 
 export default function ComponentNav(props: ComponentNavProps) {
-  const { component, currentTask, isInProgress, isPending, projectBinding, projectBindingErrors } =
-    props;
+  const { component, currentTask, isInProgress, isPending, projectBindingErrors } = props;
 
   React.useEffect(() => {
     const { breadcrumbs, key, name } = component;
@@ -78,7 +73,7 @@ export default function ComponentNav(props: ComponentNavProps) {
             style={{ top: `${top}px` }}
           >
             <div className="sw-min-h-10 sw-flex sw-justify-between">
-              <Header component={component} projectBinding={projectBinding} />
+              <Header component={component} />
               <HeaderMeta
                 component={component}
                 currentTask={currentTask}
index 92e82ebb4dc344de19dcb57de21f950e680ceb96..42977bf41d97e8e63de828e5debf059360730ca4 100644 (file)
@@ -18,7 +18,6 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { ProjectAlmBindingResponse } from '../../../../types/alm-settings';
 import { Component } from '../../../../types/types';
 import { CurrentUser } from '../../../../types/users';
 import withCurrentUserContext from '../../current-user/withCurrentUserContext';
@@ -28,17 +27,16 @@ import BranchLikeNavigation from './branch-like/BranchLikeNavigation';
 export interface HeaderProps {
   component: Component;
   currentUser: CurrentUser;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 export function Header(props: HeaderProps) {
-  const { component, currentUser, projectBinding } = props;
+  const { component, currentUser } = props;
 
   return (
     <div className="sw-flex sw-flex-shrink sw-items-center">
       <Breadcrumb component={component} currentUser={currentUser} />
 
-      <BranchLikeNavigation component={component} projectBinding={projectBinding} />
+      <BranchLikeNavigation component={component} />
     </div>
   );
 }
index 4b5157618867001c444799942a3038aef236e8e7..0d22beda1d89b634b34beca20ea9a597027faed6 100644 (file)
@@ -20,8 +20,8 @@
 import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
+import AlmSettingsServiceMock from '../../../../../api/mocks/AlmSettingsServiceMock';
 import BranchesServiceMock from '../../../../../api/mocks/BranchesServiceMock';
-import { mockProjectAlmBindingResponse } from '../../../../../helpers/mocks/alm-settings';
 import { mockMainBranch, mockPullRequest } from '../../../../../helpers/mocks/branch-like';
 import { mockComponent } from '../../../../../helpers/mocks/component';
 import { mockCurrentUser, mockLoggedInUser } from '../../../../../helpers/testMocks';
@@ -37,8 +37,12 @@ jest.mock('../../../../../api/favorites', () => ({
 }));
 
 const handler = new BranchesServiceMock();
+const almHandler = new AlmSettingsServiceMock();
 
-beforeEach(() => handler.reset());
+beforeEach(() => {
+  handler.reset();
+  almHandler.reset();
+});
 
 it('should render correctly when there is only 1 branch', async () => {
   handler.emptyBranchesAndPullRequest();
@@ -153,14 +157,15 @@ it('should show the correct help tooltip for applications', async () => {
 it('should show the correct help tooltip when branch support is not enabled', async () => {
   handler.emptyBranchesAndPullRequest();
   handler.addBranch(mockMainBranch());
+  almHandler.handleSetProjectBinding(AlmKeys.GitLab, {
+    almSetting: 'key',
+    project: 'header-project',
+    repository: 'header-project',
+    monorepo: true,
+  });
   renderHeader(
     {
       currentUser: mockLoggedInUser(),
-      projectBinding: mockProjectAlmBindingResponse({
-        alm: AlmKeys.GitLab,
-        key: 'key',
-        monorepo: true,
-      }),
     },
     []
   );
index 160b46a5ccfd6771cc85b23aa309fbd87990a846..0c42dd26b86f70680b9a3ff1c8668a5411de725c 100644 (file)
@@ -24,29 +24,28 @@ import Link from '../../../../../components/common/Link';
 import HelpTooltip from '../../../../../components/controls/HelpTooltip';
 import { translate, translateWithParameters } from '../../../../../helpers/l10n';
 import { getApplicationAdminUrl } from '../../../../../helpers/urls';
-import { ProjectAlmBindingResponse } from '../../../../../types/alm-settings';
+import { useProjectBindingQuery } from '../../../../../queries/devops-integration';
+import { AlmKeys } from '../../../../../types/alm-settings';
 import { Component } from '../../../../../types/types';
 
 interface Props {
   component: Component;
   isApplication: boolean;
-  projectBinding?: ProjectAlmBindingResponse;
   hasManyBranches: boolean;
   canAdminComponent?: boolean;
   branchSupportEnabled: boolean;
-  isGitLab: boolean;
 }
 
 export default function BranchHelpTooltip({
   component,
   isApplication,
-  projectBinding,
   hasManyBranches,
   canAdminComponent,
   branchSupportEnabled,
-  isGitLab,
 }: Props) {
   const helpIcon = <HelperHintIcon aria-label="help-tooltip" />;
+  const { data: projectBinding } = useProjectBindingQuery(component.key);
+  const isGitLab = projectBinding != null && projectBinding.alm === AlmKeys.GitLab;
 
   if (isApplication) {
     if (!hasManyBranches && canAdminComponent) {
@@ -71,7 +70,7 @@ export default function BranchHelpTooltip({
       return (
         <DocumentationTooltip
           content={
-            projectBinding !== undefined
+            projectBinding != null
               ? translateWithParameters(
                   `branch_like_navigation.no_branch_support.content_x.${isGitLab ? 'mr' : 'pr'}`,
                   translate('alm', projectBinding.alm)
@@ -87,7 +86,7 @@ export default function BranchHelpTooltip({
             },
           ]}
           title={
-            projectBinding !== undefined
+            projectBinding != null
               ? translate('branch_like_navigation.no_branch_support.title', isGitLab ? 'mr' : 'pr')
               : translate('branch_like_navigation.no_branch_support.title')
           }
index 3b20d3b6f891f8df8cfb73f0d22ba32ad8f7850d..a0d257f6eae15eb41359d436c327c739b2ece67e 100644 (file)
@@ -23,7 +23,6 @@ import EscKeydownHandler from '../../../../../components/controls/EscKeydownHand
 import FocusOutHandler from '../../../../../components/controls/FocusOutHandler';
 import OutsideClickHandler from '../../../../../components/controls/OutsideClickHandler';
 import { useBranchesQuery } from '../../../../../queries/branch';
-import { AlmKeys, ProjectAlmBindingResponse } from '../../../../../types/alm-settings';
 import { ComponentQualifier } from '../../../../../types/component';
 import { Feature } from '../../../../../types/features';
 import { Component } from '../../../../../types/types';
@@ -37,14 +36,12 @@ import PRLink from './PRLink';
 
 export interface BranchLikeNavigationProps extends WithAvailableFeaturesProps {
   component: Component;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 export function BranchLikeNavigation(props: BranchLikeNavigationProps) {
   const {
     component,
     component: { configuration },
-    projectBinding,
   } = props;
 
   const { data: { branchLikes, branchLike: currentBranchLike } = { branchLikes: [] } } =
@@ -56,7 +53,6 @@ export function BranchLikeNavigation(props: BranchLikeNavigationProps) {
   }
 
   const isApplication = component.qualifier === ComponentQualifier.Application;
-  const isGitLab = projectBinding !== undefined && projectBinding.alm === AlmKeys.GitLab;
 
   const branchSupportEnabled = props.hasFeature(Feature.BranchSupport);
   const canAdminComponent = configuration?.showSettings;
@@ -114,11 +110,9 @@ export function BranchLikeNavigation(props: BranchLikeNavigationProps) {
           <BranchHelpTooltip
             component={component}
             isApplication={isApplication}
-            projectBinding={projectBinding}
             hasManyBranches={hasManyBranches}
             canAdminComponent={canAdminComponent}
             branchSupportEnabled={branchSupportEnabled}
-            isGitLab={isGitLab}
           />
         </div>
 
index cef93cc97d22c703da1b79e4e804cf9b62f29beb..8c0cb2c306a886069cfe8ed7eeb46404507d9661 100644 (file)
@@ -41,7 +41,6 @@ import {
   extractStatusConditionsFromProjectStatus,
 } from '../../../helpers/qualityGates';
 import { isDefined } from '../../../helpers/types';
-import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
 import { ApplicationPeriod } from '../../../types/application';
 import { Branch, BranchLike } from '../../../types/branch-like';
 import { ComponentQualifier } from '../../../types/component';
@@ -57,7 +56,6 @@ interface Props {
   branch?: Branch;
   branchesEnabled?: boolean;
   component: Component;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 interface State {
@@ -398,7 +396,7 @@ export default class BranchOverview extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { branch, branchesEnabled, component, projectBinding } = this.props;
+    const { branch, branchesEnabled, component } = this.props;
     const {
       analyses,
       appLeak,
@@ -436,7 +434,6 @@ export default class BranchOverview extends React.PureComponent<Props, State> {
         metrics={metrics}
         onGraphChange={this.handleGraphChange}
         period={period}
-        projectBinding={projectBinding}
         projectIsEmpty={projectIsEmpty}
         qgStatuses={qgStatuses}
       />
index 06853bf92d6e137cad8399bc946a0629c0e82f28..9a7654ecceada73985bfe48b9b8d09d47408bf9e 100644 (file)
@@ -21,7 +21,6 @@ import { LargeCenteredLayout, PageContentFontWrapper } from 'design-system';
 import * as React from 'react';
 import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
 import { parseDate } from '../../../helpers/dates';
-import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
 import { ApplicationPeriod } from '../../../types/application';
 import { Branch } from '../../../types/branch-like';
 import { ComponentQualifier } from '../../../types/component';
@@ -49,7 +48,6 @@ export interface BranchOverviewRendererProps {
   metrics?: Metric[];
   onGraphChange: (graph: GraphType) => void;
   period?: Period;
-  projectBinding?: ProjectAlmBindingResponse;
   projectIsEmpty?: boolean;
   qgStatuses?: QualityGateStatus[];
 }
@@ -70,7 +68,6 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp
     metrics = [],
     onGraphChange,
     period,
-    projectBinding,
     projectIsEmpty,
     qgStatuses,
   } = props;
@@ -83,7 +80,6 @@ export default function BranchOverviewRenderer(props: BranchOverviewRendererProp
         component={component}
         branchesEnabled={branchesEnabled}
         detectedCIOnLastAnalysis={detectedCIOnLastAnalysis}
-        projectBinding={projectBinding}
       />
       <LargeCenteredLayout>
         <PageContentFontWrapper>
index 8f5334855695c6b045a70bbddd62ffe21f20a4e0..9f4c0f2838cc39fdd06d0a71207ff8ead1579f32 100644 (file)
@@ -24,7 +24,7 @@ import Link from '../../../components/common/Link';
 import DismissableAlert from '../../../components/ui/DismissableAlert';
 import { translate } from '../../../helpers/l10n';
 import { queryToSearch } from '../../../helpers/urls';
-import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
+import { useProjectBindingQuery } from '../../../queries/devops-integration';
 import { ComponentQualifier } from '../../../types/component';
 import { Component } from '../../../types/types';
 import { CurrentUser, isLoggedIn } from '../../../types/users';
@@ -35,18 +35,18 @@ export interface FirstAnalysisNextStepsNotifProps {
   component: Component;
   currentUser: CurrentUser;
   detectedCIOnLastAnalysis?: boolean;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 export function FirstAnalysisNextStepsNotif(props: FirstAnalysisNextStepsNotifProps) {
-  const { component, currentUser, branchesEnabled, detectedCIOnLastAnalysis, projectBinding } =
-    props;
+  const { component, currentUser, branchesEnabled, detectedCIOnLastAnalysis } = props;
 
-  if (!isLoggedIn(currentUser) || component.qualifier !== ComponentQualifier.Project) {
+  const { data: projectBinding, isLoading } = useProjectBindingQuery(component.key);
+
+  if (!isLoggedIn(currentUser) || component.qualifier !== ComponentQualifier.Project || isLoading) {
     return null;
   }
 
-  const showConfigurePullRequestDecoNotif = branchesEnabled && projectBinding === undefined;
+  const showConfigurePullRequestDecoNotif = branchesEnabled && projectBinding == null;
   const showConfigureCINotif =
     detectedCIOnLastAnalysis !== undefined ? !detectedCIOnLastAnalysis : false;
 
index 93f8b78d68731e1d55da549f8c498ef0fa71f00d..ab51129a308555aa68bfa2271bdc592516e597b0 100644 (file)
@@ -22,6 +22,7 @@ import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { getMeasuresWithPeriodAndMetrics } from '../../../../api/measures';
+import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
 import { getProjectActivity } from '../../../../api/projectActivity';
 import {
   getApplicationQualityGate,
@@ -193,8 +194,14 @@ jest.mock('../../../../components/activity-graph/utils', () => {
   };
 });
 
+const almHandler = new AlmSettingsServiceMock();
+
 beforeEach(jest.clearAllMocks);
 
+afterEach(() => {
+  almHandler.reset();
+});
+
 describe('project overview', () => {
   it('should show a successful QG', async () => {
     const user = userEvent.setup();
index dc8d50d745a19b2a67fee548a977094743ce3705..f1edda56c3fb1fa853487c9f1250570750fee77f 100644 (file)
@@ -27,7 +27,6 @@ import Suggestions from '../../../components/embed-docs-modal/Suggestions';
 import { isPullRequest } from '../../../helpers/branch-like';
 import { translate } from '../../../helpers/l10n';
 import { useBranchesQuery } from '../../../queries/branch';
-import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
 import { isPortfolioLike } from '../../../types/component';
 import { Feature } from '../../../types/features';
 import { Component } from '../../../types/types';
@@ -37,11 +36,10 @@ import EmptyOverview from './EmptyOverview';
 
 interface AppProps extends WithAvailableFeaturesProps {
   component: Component;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 export function App(props: AppProps) {
-  const { component, projectBinding } = props;
+  const { component } = props;
   const branchSupportEnabled = props.hasFeature(Feature.BranchSupport);
   const { data } = useBranchesQuery(component);
 
@@ -76,7 +74,6 @@ export function App(props: AppProps) {
               branch={branchLike}
               branchesEnabled={branchSupportEnabled}
               component={component}
-              projectBinding={projectBinding}
             />
           )}
         </main>
index 0b452177aca0b7d055f551c2748cbbe091593808..d6e3ecee691a9076c650561ab99e565a55e0419b 100644 (file)
@@ -31,7 +31,7 @@ import ApplyTemplate from './ApplyTemplate';
 
 interface Props {
   component: Component;
-  isGitHubProject: boolean;
+  isGitHubProject?: boolean;
   loadHolders: () => void;
   loading: boolean;
 }
index 1f291c94188f969fb21778d4ceab7bf1371b1788..9def5136bc472c95bedc55cdba1ace9bd3c736ac 100644 (file)
@@ -31,8 +31,8 @@ import {
   PERMISSIONS_ORDER_BY_QUALIFIER,
   convertToPermissionDefinitions,
 } from '../../../../helpers/permissions';
+import { useIsGitHubProjectQuery } from '../../../../queries/devops-integration';
 import { useGithubProvisioningEnabledQuery } from '../../../../queries/identity-provider';
-import { AlmKeys } from '../../../../types/alm-settings';
 import { ComponentContextShape, Visibility } from '../../../../types/component';
 import { Permissions } from '../../../../types/permissions';
 import { Component, Paging, PermissionGroup, PermissionUser } from '../../../../types/types';
@@ -322,7 +322,7 @@ class PermissionsProjectApp extends React.PureComponent<Props, State> {
   };
 
   render() {
-    const { component, projectBinding } = this.props;
+    const { component } = this.props;
     const {
       filter,
       groups,
@@ -342,46 +342,51 @@ class PermissionsProjectApp extends React.PureComponent<Props, State> {
       order = without(order, Permissions.Browse, Permissions.CodeViewer);
     }
     const permissions = convertToPermissionDefinitions(order, 'projects_role');
-    const isGitHubProject = projectBinding?.alm === AlmKeys.GitHub;
 
     return (
       <main className="page page-limited" id="project-permissions-page">
         <Helmet defer={false} title={translate('permissions.page')} />
 
-        <PageHeader
-          component={component}
-          isGitHubProject={isGitHubProject}
-          loadHolders={this.loadHolders}
-          loading={loading}
-        />
-        <div>
-          <UseQuery query={useGithubProvisioningEnabledQuery}>
-            {({ data: githubProvisioningStatus, isFetching }) => (
-              <VisibilitySelector
-                canTurnToPrivate={canTurnToPrivate}
-                className="sw-flex big-spacer-top big-spacer-bottom"
-                onChange={this.handleVisibilityChange}
-                loading={loading || isFetching}
-                disabled={isGitHubProject && !!githubProvisioningStatus}
-                visibility={component.visibility}
+        <UseQuery query={useIsGitHubProjectQuery} args={[component.key]}>
+          {({ data: isGitHubProject }) => (
+            <>
+              <PageHeader
+                component={component}
+                isGitHubProject={isGitHubProject}
+                loadHolders={this.loadHolders}
+                loading={loading}
               />
-            )}
-          </UseQuery>
-
-          {disclaimer && (
-            <PublicProjectDisclaimer
-              component={component}
-              onClose={this.handleCloseDisclaimer}
-              onConfirm={this.handleTurnProjectToPublic}
-            />
+              <div>
+                <UseQuery query={useGithubProvisioningEnabledQuery}>
+                  {({ data: githubProvisioningStatus, isFetching }) => (
+                    <VisibilitySelector
+                      canTurnToPrivate={canTurnToPrivate}
+                      className="sw-flex big-spacer-top big-spacer-bottom"
+                      onChange={this.handleVisibilityChange}
+                      loading={loading || isFetching}
+                      disabled={isGitHubProject && !!githubProvisioningStatus}
+                      visibility={component.visibility}
+                    />
+                  )}
+                </UseQuery>
+
+                {disclaimer && (
+                  <PublicProjectDisclaimer
+                    component={component}
+                    onClose={this.handleCloseDisclaimer}
+                    onConfirm={this.handleTurnProjectToPublic}
+                  />
+                )}
+              </div>
+            </>
           )}
-        </div>
+        </UseQuery>
+
         <AllHoldersList
           filter={filter}
           onGrantPermissionToGroup={this.handleGrantPermissionToGroup}
           onGrantPermissionToUser={this.handleGrantPermissionToUser}
           groups={groups}
-          isGitHubProject={isGitHubProject}
           groupsPaging={groupsPaging}
           onFilter={this.handleFilterChange}
           onLoadMore={this.handleLoadMore}
index 59323401c05c079cee8c6848c172461619cb76f0..989bcc851dd347d1657e3362b56ebcfb04f8b055 100644 (file)
@@ -18,8 +18,9 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { act, screen } from '@testing-library/react';
+import { act, screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
+import AlmSettingsServiceMock from '../../../../../api/mocks/AlmSettingsServiceMock';
 import AuthenticationServiceMock from '../../../../../api/mocks/AuthenticationServiceMock';
 import PermissionsServiceMock from '../../../../../api/mocks/PermissionsServiceMock';
 import { mockComponent } from '../../../../../helpers/mocks/component';
@@ -46,14 +47,17 @@ import { getPageObject } from '../../../test-utils';
 
 let serviceMock: PermissionsServiceMock;
 let authHandler: AuthenticationServiceMock;
+let almHandler: AlmSettingsServiceMock;
 beforeAll(() => {
   serviceMock = new PermissionsServiceMock();
   authHandler = new AuthenticationServiceMock();
+  almHandler = new AlmSettingsServiceMock();
 });
 
 afterEach(() => {
   serviceMock.reset();
   authHandler.reset();
+  almHandler.reset();
 });
 
 describe('rendering', () => {
@@ -237,11 +241,13 @@ it('should not allow to change visibility for GH Project with auto-provisioning'
   const user = userEvent.setup();
   const ui = getPageObject(user);
   authHandler.githubProvisioningStatus = true;
-  renderPermissionsProjectApp(
-    {},
-    { featureList: [Feature.GithubProvisioning] },
-    { projectBinding: { alm: AlmKeys.GitHub, key: 'test', repository: 'test', monorepo: false } }
-  );
+  almHandler.handleSetProjectBinding(AlmKeys.GitHub, {
+    almSetting: 'test',
+    repository: 'test',
+    monorepo: false,
+    project: 'my-project',
+  });
+  renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] });
   await ui.appLoaded();
 
   expect(ui.visibilityRadio(Visibility.Public).get()).toBeDisabled();
@@ -257,11 +263,13 @@ it('should allow to change visibility for non-GH Project', async () => {
   const user = userEvent.setup();
   const ui = getPageObject(user);
   authHandler.githubProvisioningStatus = true;
-  renderPermissionsProjectApp(
-    {},
-    { featureList: [Feature.GithubProvisioning] },
-    { projectBinding: { alm: AlmKeys.Azure, key: 'test', repository: 'test', monorepo: false } }
-  );
+  almHandler.handleSetProjectBinding(AlmKeys.Azure, {
+    almSetting: 'test',
+    repository: 'test',
+    monorepo: false,
+    project: 'my-project',
+  });
+  renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] });
   await ui.appLoaded();
 
   expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled');
@@ -277,11 +285,13 @@ it('should allow to change visibility for GH Project with disabled auto-provisio
   const user = userEvent.setup();
   const ui = getPageObject(user);
   authHandler.githubProvisioningStatus = false;
-  renderPermissionsProjectApp(
-    {},
-    { featureList: [Feature.GithubProvisioning] },
-    { projectBinding: { alm: AlmKeys.GitHub, key: 'test', repository: 'test', monorepo: false } }
-  );
+  almHandler.handleSetProjectBinding(AlmKeys.GitHub, {
+    almSetting: 'test',
+    repository: 'test',
+    monorepo: false,
+    project: 'my-project',
+  });
+  renderPermissionsProjectApp({}, { featureList: [Feature.GithubProvisioning] });
   await ui.appLoaded();
 
   expect(ui.visibilityRadio(Visibility.Public).get()).not.toHaveClass('disabled');
@@ -297,18 +307,25 @@ it('should have disabled permissions for GH Project', async () => {
   const user = userEvent.setup();
   const ui = getPageObject(user);
   authHandler.githubProvisioningStatus = true;
+  almHandler.handleSetProjectBinding(AlmKeys.GitHub, {
+    almSetting: 'test',
+    repository: 'test',
+    monorepo: false,
+    project: 'my-project',
+  });
   renderPermissionsProjectApp(
     {},
     { featureList: [Feature.GithubProvisioning] },
     {
       component: mockComponent({ visibility: Visibility.Private }),
-      projectBinding: { alm: AlmKeys.GitHub, key: 'test', repository: 'test', monorepo: false },
     }
   );
   await ui.appLoaded();
 
   expect(ui.pageTitle.get()).toBeInTheDocument();
-  expect(ui.pageTitle.get()).toHaveAccessibleName(/project_permission.github_managed/);
+  await waitFor(() =>
+    expect(ui.pageTitle.get()).toHaveAccessibleName(/project_permission.github_managed/)
+  );
   expect(ui.pageTitle.byRole('img').get()).toBeInTheDocument();
   expect(ui.githubExplanations.get()).toBeInTheDocument();
 
@@ -379,12 +396,15 @@ it('should allow to change permissions for GH Project without auto-provisioning'
   const user = userEvent.setup();
   const ui = getPageObject(user);
   authHandler.githubProvisioningStatus = false;
+  almHandler.handleSetProjectBinding(AlmKeys.GitHub, {
+    almSetting: 'test',
+    repository: 'test',
+    monorepo: false,
+    project: 'my-project',
+  });
   renderPermissionsProjectApp(
     { visibility: Visibility.Private },
-    { featureList: [Feature.GithubProvisioning] },
-    {
-      projectBinding: { alm: AlmKeys.GitHub, key: 'test', repository: 'test', monorepo: false },
-    }
+    { featureList: [Feature.GithubProvisioning] }
   );
   await ui.appLoaded();
 
@@ -423,15 +443,20 @@ function renderPermissionsProjectApp(
   contextOverride: Partial<RenderContext> = {},
   componentContextOverride: Partial<ComponentContextShape> = {}
 ) {
-  return renderAppWithComponentContext('project_roles', projectPermissionsRoutes, contextOverride, {
-    component: mockComponent({
-      visibility: Visibility.Public,
-      configuration: {
-        canUpdateProjectVisibilityToPrivate: true,
-        canApplyPermissionTemplate: true,
-      },
-      ...override,
-    }),
-    ...componentContextOverride,
-  });
+  return renderAppWithComponentContext(
+    'project_roles?id=my-project',
+    projectPermissionsRoutes,
+    contextOverride,
+    {
+      component: mockComponent({
+        visibility: Visibility.Public,
+        configuration: {
+          canUpdateProjectVisibilityToPrivate: true,
+          canApplyPermissionTemplate: true,
+        },
+        ...override,
+      }),
+      ...componentContextOverride,
+    }
+  );
 }
index aaf2ec9d9838d4fe12b8e060ef242ee9c0454b1a..2e3510abe2b6435a1695de23bae57d352d6227ff 100644 (file)
  */
 import { cloneDeep } from 'lodash';
 import * as React from 'react';
-import {
-  deleteProjectAlmBinding,
-  getAlmSettings,
-  getProjectAlmBinding,
-  setProjectAzureBinding,
-  setProjectBitbucketBinding,
-  setProjectBitbucketCloudBinding,
-  setProjectGithubBinding,
-  setProjectGitlabBinding,
-  validateProjectAlmBinding,
-} from '../../../../api/alm-settings';
+import { getAlmSettings, validateProjectAlmBinding } from '../../../../api/alm-settings';
 import withCurrentUserContext from '../../../../app/components/current-user/withCurrentUserContext';
-import { throwGlobalError } from '../../../../helpers/error';
-import { HttpStatus } from '../../../../helpers/request';
 import { hasGlobalPermission } from '../../../../helpers/users';
+import {
+  useDeleteProjectAlmBindingMutation,
+  useProjectBindingQuery,
+  useSetProjectBindingMutation,
+} from '../../../../queries/devops-integration';
 import {
   AlmKeys,
   AlmSettingsInstance,
@@ -78,269 +71,202 @@ const REQUIRED_FIELDS_BY_ALM: {
 
 const INITIAL_FORM_DATA = { key: '', repository: '', monorepo: false };
 
-export class PRDecorationBinding extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = {
-    formData: cloneDeep(INITIAL_FORM_DATA),
-    instances: [],
-    isChanged: false,
-    isConfigured: false,
-    isValid: false,
-    loading: true,
-    updating: false,
-    successfullyUpdated: false,
-    checkingConfiguration: false,
-  };
+export function PRDecorationBinding(props: Props) {
+  const { component, currentUser } = props;
+  const [formData, setFormData] = React.useState<FormData>(cloneDeep(INITIAL_FORM_DATA));
+  const [instances, setInstances] = React.useState<AlmSettingsInstance[]>([]);
+  const [configurationErrors, setConfigurationErrors] = React.useState(undefined);
+  const [loading, setLoading] = React.useState(true);
+  const [successfullyUpdated, setSuccessfullyUpdated] = React.useState(false);
+  const [checkingConfiguration, setCheckingConfiguration] = React.useState(false);
+  const { data: originalData } = useProjectBindingQuery(component.key);
+  const { mutateAsync: deleteMutation, isLoading: isDeleting } = useDeleteProjectAlmBindingMutation(
+    component.key
+  );
+  const { mutateAsync: updateMutation, isLoading: isUpdating } = useSetProjectBindingMutation();
+
+  const isConfigured = !!originalData;
+  const updating = isDeleting || isUpdating;
+
+  const isValid = React.useMemo(() => {
+    const validateForm = ({ key, ...additionalFields }: State['formData']) => {
+      const selected = instances.find((i) => i.key === key);
+      if (!key || !selected) {
+        return false;
+      }
+      return REQUIRED_FIELDS_BY_ALM[selected.alm].reduce(
+        (result: boolean, field) => result && Boolean(additionalFields[field]),
+        true
+      );
+    };
 
-  componentDidMount() {
-    this.mounted = true;
-    this.fetchDefinitions();
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  fetchDefinitions = () => {
-    const project = this.props.component.key;
-    return Promise.all([getAlmSettings(project), this.getProjectBinding(project)])
-      .then(([instances, originalData]) => {
-        if (this.mounted) {
-          this.setState(({ formData }) => {
-            const newFormData = originalData || formData;
-            return {
-              formData: newFormData,
-              instances: instances || [],
-              isChanged: false,
-              isConfigured: !!originalData,
-              isValid: this.validateForm(newFormData),
-              loading: false,
-              originalData: newFormData,
-              configurationErrors: undefined,
-            };
-          });
-        }
-      })
-      .catch(() => {
-        if (this.mounted) {
-          this.setState({ loading: false });
-        }
-      })
-      .then(() => this.checkConfiguration());
+    return validateForm(formData);
+  }, [formData, instances]);
+
+  const isDataSame = (
+    { key, repository = '', slug = '', summaryCommentEnabled = false, monorepo = false }: FormData,
+    {
+      key: oKey = '',
+      repository: oRepository = '',
+      slug: oSlug = '',
+      summaryCommentEnabled: osummaryCommentEnabled = false,
+      monorepo: omonorepo = false,
+    }: FormData
+  ) => {
+    return (
+      key === oKey &&
+      repository === oRepository &&
+      slug === oSlug &&
+      summaryCommentEnabled === osummaryCommentEnabled &&
+      monorepo === omonorepo
+    );
   };
 
-  getProjectBinding(project: string): Promise<ProjectAlmBindingResponse | undefined> {
-    return getProjectAlmBinding(project).catch((response: Response) => {
-      if (response && response.status === HttpStatus.NotFound) {
-        return undefined;
-      }
-      return throwGlobalError(response);
-    });
-  }
+  const isChanged = !isDataSame(formData, originalData ?? cloneDeep(INITIAL_FORM_DATA));
 
-  catchError = () => {
-    if (this.mounted) {
-      this.setState({ updating: false });
-    }
+  React.useEffect(() => {
+    fetchDefinitions();
+  }, []);
+
+  React.useEffect(() => {
+    checkConfiguration();
+  }, [originalData]);
+
+  React.useEffect(() => {
+    setFormData((formData) => originalData ?? formData);
+  }, [originalData]);
+
+  const fetchDefinitions = () => {
+    const project = component.key;
+
+    return getAlmSettings(project)
+      .then((instances) => {
+        setInstances(instances || []);
+        setConfigurationErrors(undefined);
+        setLoading(false);
+      })
+      .catch(() => {
+        setLoading(false);
+      });
   };
 
-  handleReset = () => {
-    const { component } = this.props;
-    this.setState({ updating: true });
-    deleteProjectAlmBinding(component.key)
+  const handleReset = () => {
+    deleteMutation()
       .then(() => {
-        if (this.mounted) {
-          this.setState({
-            formData: {
-              key: '',
-              repository: '',
-              slug: '',
-              monorepo: false,
-            },
-            originalData: undefined,
-            isChanged: false,
-            isConfigured: false,
-            updating: false,
-            successfullyUpdated: true,
-            configurationErrors: undefined,
-          });
-        }
+        setFormData({
+          key: '',
+          repository: '',
+          slug: '',
+          monorepo: false,
+        });
+        setSuccessfullyUpdated(true);
+        setConfigurationErrors(undefined);
       })
-      .catch(this.catchError);
+      .catch(() => {});
   };
 
-  submitProjectAlmBinding(
+  const submitProjectAlmBinding = (
     alm: AlmKeys,
     key: string,
     almSpecificFields: Omit<FormData, 'key'>
-  ): Promise<void> {
+  ): Promise<void> => {
     const almSetting = key;
     const { repository, slug = '', monorepo = false } = almSpecificFields;
-    const project = this.props.component.key;
-
-    switch (alm) {
-      case AlmKeys.Azure: {
-        return setProjectAzureBinding({
-          almSetting,
-          project,
-          projectName: slug,
-          repositoryName: repository,
-          monorepo,
-        });
-      }
-      case AlmKeys.BitbucketServer: {
-        return setProjectBitbucketBinding({
-          almSetting,
-          project,
-          repository,
-          slug,
-          monorepo,
-        });
-      }
-      case AlmKeys.BitbucketCloud: {
-        return setProjectBitbucketCloudBinding({
-          almSetting,
-          project,
-          repository,
-          monorepo,
-        });
-      }
-      case AlmKeys.GitHub: {
-        // By default it must remain true.
-        const summaryCommentEnabled = almSpecificFields?.summaryCommentEnabled ?? true;
-        return setProjectGithubBinding({
-          almSetting,
-          project,
-          repository,
-          summaryCommentEnabled,
-          monorepo,
-        });
-      }
-
-      case AlmKeys.GitLab: {
-        return setProjectGitlabBinding({
-          almSetting,
-          project,
-          repository,
-          monorepo,
-        });
-      }
-
-      default:
-        return Promise.reject();
+    const project = component.key;
+
+    const baseParams = {
+      almSetting,
+      project,
+      repository,
+      monorepo,
+    };
+    let updateParams;
+
+    if (alm === AlmKeys.Azure || alm === AlmKeys.BitbucketServer) {
+      updateParams = {
+        alm,
+        ...baseParams,
+        slug,
+      };
+    } else if (alm === AlmKeys.GitHub) {
+      updateParams = {
+        alm,
+        ...baseParams,
+        summaryCommentEnabled: almSpecificFields?.summaryCommentEnabled ?? true,
+      };
+    } else {
+      updateParams = {
+        alm,
+        ...baseParams,
+      };
     }
-  }
 
-  checkConfiguration = async () => {
-    const {
-      component: { key: projectKey },
-    } = this.props;
+    return updateMutation(updateParams);
+  };
 
-    const { isConfigured } = this.state;
+  const checkConfiguration = async () => {
+    const projectKey = component.key;
 
     if (!isConfigured) {
       return;
     }
 
-    this.setState({ checkingConfiguration: true, configurationErrors: undefined });
+    setCheckingConfiguration(true);
+    setConfigurationErrors(undefined);
 
     const configurationErrors = await validateProjectAlmBinding(projectKey).catch((error) => error);
 
-    if (this.mounted) {
-      this.setState({ checkingConfiguration: false, configurationErrors });
-    }
+    setCheckingConfiguration(false);
+    setConfigurationErrors(configurationErrors);
   };
 
-  handleSubmit = () => {
-    this.setState({ updating: true });
-    const {
-      formData: { key, ...additionalFields },
-      instances,
-    } = this.state;
+  const handleSubmit = () => {
+    const { key, ...additionalFields } = formData;
 
     const selected = instances.find((i) => i.key === key);
     if (!key || !selected) {
       return;
     }
 
-    this.submitProjectAlmBinding(selected.alm, key, additionalFields)
+    submitProjectAlmBinding(selected.alm, key, additionalFields)
       .then(() => {
-        if (this.mounted) {
-          this.setState({
-            updating: false,
-            successfullyUpdated: true,
-          });
-        }
+        setSuccessfullyUpdated(true);
       })
-      .then(this.fetchDefinitions)
-      .catch(this.catchError);
-  };
-
-  isDataSame(
-    { key, repository = '', slug = '', summaryCommentEnabled = false, monorepo = false }: FormData,
-    {
-      key: oKey = '',
-      repository: oRepository = '',
-      slug: oSlug = '',
-      summaryCommentEnabled: osummaryCommentEnabled = false,
-      monorepo: omonorepo = false,
-    }: FormData
-  ) {
-    return (
-      key === oKey &&
-      repository === oRepository &&
-      slug === oSlug &&
-      summaryCommentEnabled === osummaryCommentEnabled &&
-      monorepo === omonorepo
-    );
-  }
-
-  handleFieldChange = (id: keyof ProjectAlmBindingResponse, value: string | boolean) => {
-    this.setState(({ formData, originalData }) => {
-      const newFormData = {
-        ...formData,
-        [id]: value,
-      };
-
-      return {
-        formData: newFormData,
-        isValid: this.validateForm(newFormData),
-        isChanged: !this.isDataSame(newFormData, originalData || cloneDeep(INITIAL_FORM_DATA)),
-        successfullyUpdated: false,
-      };
-    });
+      .then(fetchDefinitions)
+      .catch(() => {});
   };
 
-  validateForm = ({ key, ...additionalFields }: State['formData']) => {
-    const { instances } = this.state;
-    const selected = instances.find((i) => i.key === key);
-    if (!key || !selected) {
-      return false;
-    }
-    return REQUIRED_FIELDS_BY_ALM[selected.alm].reduce(
-      (result: boolean, field) => result && Boolean(additionalFields[field]),
-      true
-    );
+  const handleFieldChange = (id: keyof ProjectAlmBindingResponse, value: string | boolean) => {
+    setFormData((formData) => ({
+      ...formData,
+      [id]: value,
+    }));
+    setSuccessfullyUpdated(false);
   };
 
-  handleCheckConfiguration = async () => {
-    await this.checkConfiguration();
+  const handleCheckConfiguration = async () => {
+    await checkConfiguration();
   };
 
-  render() {
-    const { currentUser } = this.props;
-
-    return (
-      <PRDecorationBindingRenderer
-        onFieldChange={this.handleFieldChange}
-        onReset={this.handleReset}
-        onSubmit={this.handleSubmit}
-        onCheckConfiguration={this.handleCheckConfiguration}
-        isSysAdmin={hasGlobalPermission(currentUser, Permissions.Admin)}
-        {...this.state}
-      />
-    );
-  }
+  return (
+    <PRDecorationBindingRenderer
+      onFieldChange={handleFieldChange}
+      onReset={handleReset}
+      onSubmit={handleSubmit}
+      onCheckConfiguration={handleCheckConfiguration}
+      isSysAdmin={hasGlobalPermission(currentUser, Permissions.Admin)}
+      instances={instances}
+      formData={formData}
+      isChanged={isChanged}
+      isValid={isValid}
+      isConfigured={isConfigured}
+      loading={loading}
+      updating={updating}
+      successfullyUpdated={successfullyUpdated}
+      checkingConfiguration={checkingConfiguration}
+      configurationErrors={configurationErrors}
+    />
+  );
 }
 
 export default withCurrentUserContext(PRDecorationBinding);
index e37aff3ec4790155b21976f49a36266635e452d1..bfe7a890fcdbd023d8f478a83461c010477f4218 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { act } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
 import selectEvent from 'react-select-event';
@@ -34,8 +35,6 @@ import { Component } from '../../../../../types/types';
 import { CurrentUser } from '../../../../../types/users';
 import PRDecorationBinding from '../PRDecorationBinding';
 
-jest.mock('../../../../../api/alm-settings');
-
 let almSettings: AlmSettingsServiceMock;
 
 beforeAll(() => {
@@ -109,11 +108,11 @@ it.each([
       await ui.setInput(inputId, value);
     }
     // Save form and check for errors
-    await user.click(ui.saveButton.get());
-    expect(ui.validationMsg('cute error').get()).toBeInTheDocument();
+    await act(() => user.click(ui.saveButton.get()));
+    expect(await ui.validationMsg('cute error').find()).toBeInTheDocument();
 
     // Check validation with errors
-    await user.click(ui.validateButton.get());
+    await act(() => user.click(ui.validateButton.get()));
     expect(ui.validationMsg('cute error').get()).toBeInTheDocument();
 
     // Save form and check for errors
@@ -122,12 +121,12 @@ it.each([
       Object.keys(list).find((key) => key.endsWith('.repository')) as string,
       'Anything'
     );
-    await user.click(ui.saveButton.get());
+    await act(() => user.click(ui.saveButton.get()));
     expect(
       await ui.validationMsg('settings.pr_decoration.binding.check_configuration.success').find()
     ).toBeInTheDocument();
 
-    await user.click(ui.validateButton.get());
+    await act(() => user.click(ui.validateButton.get()));
     expect(
       ui.validationMsg('settings.pr_decoration.binding.check_configuration.success').get()
     ).toBeInTheDocument();
@@ -142,7 +141,7 @@ it.each([
     expect(ui.saveButton.query()).not.toBeInTheDocument();
 
     // Reset binding
-    await user.click(ui.resetButton.get());
+    await act(() => user.click(ui.resetButton.get()));
     expect(ui.input('', 'textbox').query()).not.toBeInTheDocument();
     expect(ui.input('', 'switch').query()).not.toBeInTheDocument();
   }
index bff4b922f507b09f9859dcea6436a07cae394fc3..d1aa1c6fcde2a7b50e9add7be4ffe2242c3571e6 100644 (file)
@@ -23,18 +23,16 @@ import withComponentContext from '../../../app/components/componentContext/withC
 import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
 import TutorialSelection from '../../../components/tutorials/TutorialSelection';
 import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication';
-import { ProjectAlmBindingResponse } from '../../../types/alm-settings';
 import { Component } from '../../../types/types';
 import { CurrentUser, isLoggedIn } from '../../../types/users';
 
 export interface TutorialsAppProps {
   component: Component;
   currentUser: CurrentUser;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 export function TutorialsApp(props: TutorialsAppProps) {
-  const { component, currentUser, projectBinding } = props;
+  const { component, currentUser } = props;
 
   if (!isLoggedIn(currentUser)) {
     handleRequiredAuthentication();
@@ -44,11 +42,7 @@ export function TutorialsApp(props: TutorialsAppProps) {
   return (
     <LargeCenteredLayout className="sw-pt-8">
       <PageContentFontWrapper>
-        <TutorialSelection
-          component={component}
-          currentUser={currentUser}
-          projectBinding={projectBinding}
-        />
+        <TutorialSelection component={component} currentUser={currentUser} />
       </PageContentFontWrapper>
     </LargeCenteredLayout>
   );
index 3f80139f227095216ead834dcc1056edb5041723..0d60b2fa438ff23736990c1bfd6159be43b59463 100644 (file)
@@ -47,7 +47,6 @@ interface Props {
   selectedPermission?: string;
   onSelectPermission?: (permissions?: string) => void;
   loading?: boolean;
-  isGitHubProject?: boolean;
 }
 
 export default class AllHoldersList extends React.PureComponent<Props> {
@@ -88,23 +87,13 @@ export default class AllHoldersList extends React.PureComponent<Props> {
   };
 
   render() {
-    const {
-      filter,
-      query,
-      groups,
-      users,
-      permissions,
-      selectedPermission,
-      loading,
-      isGitHubProject,
-    } = this.props;
+    const { filter, query, groups, users, permissions, selectedPermission, loading } = this.props;
     const { count, total } = this.getPaging();
 
     return (
       <>
         <HoldersList
           loading={loading}
-          isGitHubProject={isGitHubProject}
           filter={filter}
           groups={groups}
           onSelectPermission={this.props.onSelectPermission}
index 5f0eb67e3c8a42972e070c94a77f7ff26b7d4970..d286e741890c0d727512b51b708742b5c2dc75de 100644 (file)
@@ -22,6 +22,7 @@ import * as React from 'react';
 import UseQuery from '../../helpers/UseQuery';
 import { translate } from '../../helpers/l10n';
 import { isPermissionDefinitionGroup } from '../../helpers/permissions';
+import { useIsGitHubProjectQuery } from '../../queries/devops-integration';
 import { useGithubProvisioningEnabledQuery } from '../../queries/identity-provider';
 import { Dict, PermissionDefinitions, PermissionGroup, PermissionUser } from '../../types/types';
 import GroupHolder from './GroupHolder';
@@ -39,7 +40,6 @@ interface Props {
   permissions: PermissionDefinitions;
   query?: string;
   selectedPermission?: string;
-  isGitHubProject?: boolean;
   users: PermissionUser[];
 }
 
@@ -103,36 +103,40 @@ export default class HoldersList extends React.PureComponent<Props, State> {
   }
 
   renderItem(item: PermissionUser | PermissionGroup, permissions: PermissionDefinitions) {
-    const { isGitHubProject, selectedPermission, isComponentPrivate } = this.props;
+    const { selectedPermission, isComponentPrivate } = this.props;
     return (
-      <UseQuery key={this.getKey(item)} query={useGithubProvisioningEnabledQuery}>
-        {({ data: githubProvisioningStatus }) => (
-          <>
-            {this.isPermissionUser(item) ? (
-              <UserHolder
-                key={`user-${item.login}`}
-                onToggle={this.handleUserToggle}
-                permissions={permissions}
-                selectedPermission={selectedPermission}
-                user={item}
-                disabled={isGitHubProject && !!githubProvisioningStatus && item.managed}
-                removeOnly={isGitHubProject && !!githubProvisioningStatus && !item.managed}
-                isGitHubProject={isGitHubProject}
-              />
-            ) : (
-              <GroupHolder
-                group={item}
-                isComponentPrivate={isComponentPrivate}
-                key={`group-${item.id || item.name}`}
-                onToggle={this.handleGroupToggle}
-                permissions={permissions}
-                selectedPermission={selectedPermission}
-                disabled={isGitHubProject && !!githubProvisioningStatus && item.managed}
-                removeOnly={isGitHubProject && !!githubProvisioningStatus && !item.managed}
-                isGitHubProject={isGitHubProject}
-              />
+      <UseQuery key={this.getKey(item)} query={useIsGitHubProjectQuery}>
+        {({ data: isGitHubProject }) => (
+          <UseQuery query={useGithubProvisioningEnabledQuery}>
+            {({ data: githubProvisioningStatus }) => (
+              <>
+                {this.isPermissionUser(item) ? (
+                  <UserHolder
+                    key={`user-${item.login}`}
+                    onToggle={this.handleUserToggle}
+                    permissions={permissions}
+                    selectedPermission={selectedPermission}
+                    user={item}
+                    disabled={isGitHubProject && !!githubProvisioningStatus && item.managed}
+                    removeOnly={isGitHubProject && !!githubProvisioningStatus && !item.managed}
+                    isGitHubProject={isGitHubProject}
+                  />
+                ) : (
+                  <GroupHolder
+                    group={item}
+                    isComponentPrivate={isComponentPrivate}
+                    key={`group-${item.id || item.name}`}
+                    onToggle={this.handleGroupToggle}
+                    permissions={permissions}
+                    selectedPermission={selectedPermission}
+                    disabled={isGitHubProject && !!githubProvisioningStatus && item.managed}
+                    removeOnly={isGitHubProject && !!githubProvisioningStatus && !item.managed}
+                    isGitHubProject={isGitHubProject}
+                  />
+                )}
+              </>
             )}
-          </>
+          </UseQuery>
         )}
       </UseQuery>
     );
index 3cb64f78607fa978e13db74a6d2948836c4ef83d..574945833ac948fd047c223fa7b32b94d2be4f51 100644 (file)
@@ -23,7 +23,8 @@ import { getScannableProjects } from '../../api/components';
 import { getValue } from '../../api/settings';
 import { getHostUrl } from '../../helpers/urls';
 import { hasGlobalPermission } from '../../helpers/users';
-import { AlmSettingsInstance, ProjectAlmBindingResponse } from '../../types/alm-settings';
+import { useProjectBindingQuery } from '../../queries/devops-integration';
+import { AlmSettingsInstance } from '../../types/alm-settings';
 import { Permissions } from '../../types/permissions';
 import { SettingsKey } from '../../types/settings';
 import { Component } from '../../types/types';
@@ -35,100 +36,79 @@ import { TutorialModes } from './types';
 interface Props {
   component: Component;
   currentUser: LoggedInUser;
-  projectBinding?: ProjectAlmBindingResponse;
   willRefreshAutomatically?: boolean;
   location: Location;
 }
 
-interface State {
-  almBinding?: AlmSettingsInstance;
-  currentUserCanScanProject: boolean;
-  baseUrl: string;
-  loading: boolean;
-}
-
-export class TutorialSelection extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = {
-    currentUserCanScanProject: false,
-    baseUrl: getHostUrl(),
-    loading: true,
-  };
-
-  async componentDidMount() {
-    this.mounted = true;
-
-    await Promise.all([this.fetchAlmBindings(), this.fetchBaseUrl(), this.checkUserPermissions()]);
-
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
+export function TutorialSelection(props: Props) {
+  const { component, currentUser, location, willRefreshAutomatically } = props;
+  const [currentUserCanScanProject, setCurrentUserCanScanProject] = React.useState(false);
+  const [baseUrl, setBaseUrl] = React.useState(getHostUrl());
+  const [loading, setLoading] = React.useState(true);
+  const [loadingAlm, setLoadingAlm] = React.useState(false);
+  const [almBinding, setAlmBinding] = React.useState<AlmSettingsInstance | undefined>(undefined);
+  const { data: projectBinding } = useProjectBindingQuery(component.key);
+
+  React.useEffect(() => {
+    const checkUserPermissions = async () => {
+      if (hasGlobalPermission(currentUser, Permissions.Scan)) {
+        setCurrentUserCanScanProject(true);
+        return Promise.resolve();
+      }
 
-  checkUserPermissions = async () => {
-    const { component, currentUser } = this.props;
+      const { projects } = await getScannableProjects();
+      setCurrentUserCanScanProject(projects.find((p) => p.key === component.key) !== undefined);
 
-    if (hasGlobalPermission(currentUser, Permissions.Scan)) {
-      this.setState({ currentUserCanScanProject: true });
       return Promise.resolve();
-    }
-
-    const { projects } = await getScannableProjects();
-    this.setState({
-      currentUserCanScanProject: projects.find((p) => p.key === component.key) !== undefined,
-    });
+    };
 
-    return Promise.resolve();
-  };
-
-  fetchAlmBindings = async () => {
-    const { component, projectBinding } = this.props;
-
-    if (projectBinding !== undefined) {
-      const almSettings = await getAlmSettingsNoCatch(component.key).catch(() => undefined);
-      if (this.mounted) {
+    const fetchBaseUrl = async () => {
+      const setting = await getValue({ key: SettingsKey.ServerBaseUrl }).catch(() => undefined);
+      const baseUrl = setting?.value;
+      if (baseUrl && baseUrl.length > 0) {
+        setBaseUrl(baseUrl);
+      }
+    };
+
+    Promise.all([fetchBaseUrl(), checkUserPermissions()])
+      .then(() => {
+        setLoading(false);
+      })
+      .catch(() => {});
+  }, [component.key, currentUser]);
+
+  React.useEffect(() => {
+    const fetchAlmBindings = async () => {
+      if (projectBinding != null) {
+        setLoadingAlm(true);
+        const almSettings = await getAlmSettingsNoCatch(component.key).catch(() => undefined);
         let almBinding;
         if (almSettings !== undefined) {
           almBinding = almSettings.find((d) => d.key === projectBinding.key);
         }
-        this.setState({ almBinding });
+        setAlmBinding(almBinding);
+        setLoadingAlm(false);
       }
-    }
-  };
-
-  fetchBaseUrl = async () => {
-    const setting = await getValue({ key: SettingsKey.ServerBaseUrl }).catch(() => undefined);
-    const baseUrl = setting?.value;
-    if (baseUrl && baseUrl.length > 0 && this.mounted) {
-      this.setState({ baseUrl });
-    }
-  };
-
-  render() {
-    const { component, currentUser, location, projectBinding, willRefreshAutomatically } =
-      this.props;
-    const { almBinding, baseUrl, currentUserCanScanProject, loading } = this.state;
-
-    const selectedTutorial: TutorialModes | undefined = location.query?.selectedTutorial;
-
-    return (
-      <TutorialSelectionRenderer
-        almBinding={almBinding}
-        baseUrl={baseUrl}
-        component={component}
-        currentUser={currentUser}
-        currentUserCanScanProject={currentUserCanScanProject}
-        loading={loading}
-        projectBinding={projectBinding}
-        selectedTutorial={selectedTutorial}
-        willRefreshAutomatically={willRefreshAutomatically}
-      />
-    );
-  }
+    };
+
+    fetchAlmBindings().catch(() => {});
+  }, [component.key, projectBinding]);
+
+  const selectedTutorial: TutorialModes | undefined = location.query?.selectedTutorial;
+
+  return (
+    <TutorialSelectionRenderer
+      almBinding={almBinding}
+      baseUrl={baseUrl}
+      component={component}
+      currentUser={currentUser}
+      currentUserCanScanProject={currentUserCanScanProject}
+      loading={loading || loadingAlm}
+      projectBinding={projectBinding}
+      selectedTutorial={selectedTutorial}
+      willRefreshAutomatically={willRefreshAutomatically}
+    />
+  );
 }
 
 export default withRouter(TutorialSelection);
index 5ce4cf487cac6d0a29e2e9637a02d2ee75fed312..a3d6ad33432666356139256eae9aee27cf556704 100644 (file)
@@ -114,7 +114,7 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
   let showAzurePipelines = true;
   let showJenkins = true;
 
-  if (projectBinding !== undefined) {
+  if (projectBinding != null) {
     showGitHubActions = projectBinding.alm === AlmKeys.GitHub;
     showGitLabCICD = projectBinding.alm === AlmKeys.GitLab;
     showBitbucketPipelines = projectBinding.alm === AlmKeys.BitbucketCloud;
@@ -227,7 +227,6 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
           component={component}
           currentUser={currentUser}
           mainBranchName={mainBranchName}
-          projectBinding={projectBinding}
           willRefreshAutomatically={willRefreshAutomatically}
         />
       )}
@@ -239,7 +238,6 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
           component={component}
           currentUser={currentUser}
           mainBranchName={mainBranchName}
-          projectBinding={projectBinding}
           willRefreshAutomatically={willRefreshAutomatically}
         />
       )}
@@ -249,7 +247,6 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender
           almBinding={almBinding}
           baseUrl={baseUrl}
           component={component}
-          projectBinding={projectBinding}
           willRefreshAutomatically={willRefreshAutomatically}
         />
       )}
index 511fadce2f05cd75488145d1289cfcee6a3b4963..016d2b4398d881fc6df9f9a6cec0ae5124194db9 100644 (file)
@@ -21,11 +21,10 @@ import { screen, waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
 import * as React from 'react';
-import { getAlmSettingsNoCatch } from '../../../api/alm-settings';
 import { getScannableProjects } from '../../../api/components';
+import AlmSettingsServiceMock from '../../../api/mocks/AlmSettingsServiceMock';
 import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock';
 import UserTokensMock from '../../../api/mocks/UserTokensMock';
-import { mockProjectAlmBindingResponse } from '../../../helpers/mocks/alm-settings';
 import { mockComponent } from '../../../helpers/mocks/component';
 import { mockLoggedInUser } from '../../../helpers/testMocks';
 import { renderApp } from '../../../helpers/testReactTestingUtils';
@@ -47,25 +46,24 @@ jest.mock('../../../helpers/urls', () => ({
   getHostUrl: jest.fn().mockReturnValue('http://host.url'),
 }));
 
-jest.mock('../../../api/alm-settings', () => ({
-  getAlmSettingsNoCatch: jest.fn().mockRejectedValue(null),
-}));
-
 jest.mock('../../../api/components', () => ({
   getScannableProjects: jest.fn().mockResolvedValue({ projects: [] }),
 }));
 
 let settingsMock: SettingsServiceMock;
 let tokenMock: UserTokensMock;
+let almMock: AlmSettingsServiceMock;
 
 beforeAll(() => {
   settingsMock = new SettingsServiceMock();
   tokenMock = new UserTokensMock();
+  almMock = new AlmSettingsServiceMock();
 });
 
 afterEach(() => {
   tokenMock.reset();
   settingsMock.reset();
+  almMock.reset();
 });
 
 beforeEach(jest.clearAllMocks);
@@ -112,27 +110,29 @@ it.each([
   [AlmKeys.BitbucketServer, [TutorialModes.Jenkins]],
   [AlmKeys.BitbucketCloud, [TutorialModes.BitbucketPipelines, TutorialModes.Jenkins]],
 ])('should show correct buttons if project is bound to %s', async (alm, modes) => {
-  renderTutorialSelection({ projectBinding: mockProjectAlmBindingResponse({ alm }) });
+  almMock.handleSetProjectBinding(alm, {
+    project: 'foo',
+    almSetting: 'foo',
+    repository: 'repo',
+    monorepo: false,
+  });
+  renderTutorialSelection();
   await waitOnDataLoaded();
 
   modes.forEach((mode) => expect(ui.chooseTutorialLink(mode).get()).toBeInTheDocument());
 });
 
 it('should correctly fetch the corresponding ALM setting', async () => {
-  jest
-    .mocked(getAlmSettingsNoCatch)
-    .mockResolvedValueOnce([
-      { key: 'binding', url: 'https://enterprise.github.com', alm: AlmKeys.GitHub },
-    ]);
-  renderTutorialSelection(
-    {
-      projectBinding: mockProjectAlmBindingResponse({ alm: AlmKeys.GitHub, key: 'binding' }),
-    },
-    `tutorials?selectedTutorial=${TutorialModes.Jenkins}&id=bar`
-  );
+  almMock.handleSetProjectBinding(AlmKeys.GitHub, {
+    project: 'foo',
+    almSetting: 'conf-github-1',
+    repository: 'repo',
+    monorepo: false,
+  });
+  renderTutorialSelection({}, `tutorials?selectedTutorial=${TutorialModes.Jenkins}&id=foo`);
   await waitOnDataLoaded();
 
-  expect(screen.getByText('https://enterprise.github.com', { exact: false })).toBeInTheDocument();
+  expect(await screen.findByText('http://url', { exact: false })).toBeInTheDocument();
 });
 
 it('should correctly fetch the instance URL', async () => {
index 31c33edbc1723dacba4369fec1ccee9bef14f70c..6d0e756039fc2b7becf0d34879e77b523c910b40 100644 (file)
 import { BasicSeparator, Title, TutorialStep, TutorialStepList } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
-import {
-  AlmKeys,
-  AlmSettingsInstance,
-  ProjectAlmBindingResponse,
-} from '../../../types/alm-settings';
+import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
 import { Component } from '../../../types/types';
 import { LoggedInUser } from '../../../types/users';
 import AllSet from '../components/AllSet';
@@ -46,20 +42,12 @@ export interface BitbucketPipelinesTutorialProps {
   component: Component;
   currentUser: LoggedInUser;
   mainBranchName: string;
-  projectBinding?: ProjectAlmBindingResponse;
   willRefreshAutomatically?: boolean;
 }
 
 export default function BitbucketPipelinesTutorial(props: BitbucketPipelinesTutorialProps) {
-  const {
-    almBinding,
-    baseUrl,
-    currentUser,
-    component,
-    projectBinding,
-    willRefreshAutomatically,
-    mainBranchName,
-  } = props;
+  const { almBinding, baseUrl, currentUser, component, willRefreshAutomatically, mainBranchName } =
+    props;
 
   const [done, setDone] = React.useState<boolean>(false);
   return (
@@ -75,7 +63,6 @@ export default function BitbucketPipelinesTutorial(props: BitbucketPipelinesTuto
             baseUrl={baseUrl}
             component={component}
             currentUser={currentUser}
-            projectBinding={projectBinding}
           />
         </TutorialStep>
         <TutorialStep title={translate('onboarding.tutorial.with.bitbucket_pipelines.yaml.title')}>
@@ -101,7 +88,10 @@ export default function BitbucketPipelinesTutorial(props: BitbucketPipelinesTuto
         {done && (
           <>
             <BasicSeparator className="sw-my-10" />
-            <AllSet alm={AlmKeys.GitLab} willRefreshAutomatically={willRefreshAutomatically} />
+            <AllSet
+              alm={AlmKeys.BitbucketCloud}
+              willRefreshAutomatically={willRefreshAutomatically}
+            />
           </>
         )}
       </TutorialStepList>
index 33c096a909260122537dd025dfa44b0afddce96a..c13825a10caf9064acfcaa672a29623802e97498 100644 (file)
@@ -27,7 +27,8 @@ import {
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { translate } from '../../../helpers/l10n';
-import { AlmSettingsInstance, ProjectAlmBindingResponse } from '../../../types/alm-settings';
+import { useProjectBindingQuery } from '../../../queries/devops-integration';
+import { AlmSettingsInstance } from '../../../types/alm-settings';
 import { Component } from '../../../types/types';
 import { LoggedInUser } from '../../../types/users';
 import { InlineSnippet } from '../components/InlineSnippet';
@@ -40,11 +41,11 @@ export interface RepositoryVariablesProps {
   baseUrl: string;
   component: Component;
   currentUser: LoggedInUser;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 export default function RepositoryVariables(props: RepositoryVariablesProps) {
-  const { almBinding, baseUrl, component, currentUser, projectBinding } = props;
+  const { almBinding, baseUrl, component, currentUser } = props;
+  const { data: projectBinding } = useProjectBindingQuery(component.key);
   return (
     <>
       <FormattedMessage
index b35ef0639346c51dae8e29e89eecbe4e5cfc5be2..c89e422b868a966a9f6ce98e32e927f08e8ed915 100644 (file)
 import userEvent from '@testing-library/user-event';
 import React from 'react';
 import selectEvent from 'react-select-event';
+import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
 import UserTokensMock from '../../../../api/mocks/UserTokensMock';
-import {
-  mockAlmSettingsInstance,
-  mockProjectAlmBindingResponse,
-} from '../../../../helpers/mocks/alm-settings';
+import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings';
 import { mockComponent } from '../../../../helpers/mocks/component';
 import { mockLanguage, mockLoggedInUser } from '../../../../helpers/testMocks';
 import { RenderContext, renderApp } from '../../../../helpers/testReactTestingUtils';
@@ -49,9 +47,11 @@ jest.mock('../../../../api/settings', () => ({
 }));
 
 const tokenMock = new UserTokensMock();
+const almMock = new AlmSettingsServiceMock();
 
 afterEach(() => {
   tokenMock.reset();
+  almMock.reset();
 });
 
 const ui = {
@@ -125,15 +125,17 @@ it('should generate/delete a new token or use existing one', async () => {
 
 it('navigates between steps', async () => {
   const user = userEvent.setup();
+  almMock.handleSetProjectBinding(AlmKeys.GitHub, {
+    almSetting: 'my-project',
+    project: 'my-project',
+    repository: 'my-project',
+    monorepo: true,
+  });
   renderBitbucketPipelinesTutorial({
     almBinding: mockAlmSettingsInstance({
       alm: AlmKeys.BitbucketCloud,
       url: 'http://localhost/qube',
     }),
-    projectBinding: mockProjectAlmBindingResponse({
-      alm: AlmKeys.BitbucketCloud,
-      repository: 'my-project',
-    }),
   });
 
   // If project is bound, link to repo is visible
index e7f00e98a9d1196aeef0ae576f83fb3bcf052e76..8b37a1573c9e17608131cf308a4405e997f3c961 100644 (file)
 import { BasicSeparator, Title, TutorialStep, TutorialStepList } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
-import {
-  AlmKeys,
-  AlmSettingsInstance,
-  ProjectAlmBindingResponse,
-} from '../../../types/alm-settings';
+import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
 import { Component } from '../../../types/types';
 import { LoggedInUser } from '../../../types/users';
 import AllSet from '../components/AllSet';
@@ -38,21 +34,13 @@ export interface GitHubActionTutorialProps {
   component: Component;
   currentUser: LoggedInUser;
   mainBranchName: string;
-  projectBinding?: ProjectAlmBindingResponse;
   willRefreshAutomatically?: boolean;
 }
 
 export default function GitHubActionTutorial(props: GitHubActionTutorialProps) {
   const [done, setDone] = React.useState<boolean>(false);
-  const {
-    almBinding,
-    baseUrl,
-    currentUser,
-    component,
-    projectBinding,
-    mainBranchName,
-    willRefreshAutomatically,
-  } = props;
+  const { almBinding, baseUrl, currentUser, component, mainBranchName, willRefreshAutomatically } =
+    props;
   return (
     <>
       <Title>{translate('onboarding.tutorial.with.github_ci.title')}</Title>
@@ -66,7 +54,6 @@ export default function GitHubActionTutorial(props: GitHubActionTutorialProps) {
             baseUrl={baseUrl}
             component={component}
             currentUser={currentUser}
-            projectBinding={projectBinding}
           />
         </TutorialStep>
         <TutorialStep title={translate('onboarding.tutorial.with.github_action.yaml.title')}>
index ff606173ee8b7d5bff1cb2907d93487278549002..28b7fb42f7540e1ab3c9f85862b3a0a67247410b 100644 (file)
@@ -27,7 +27,8 @@ import {
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { translate } from '../../../helpers/l10n';
-import { AlmSettingsInstance, ProjectAlmBindingResponse } from '../../../types/alm-settings';
+import { useProjectBindingQuery } from '../../../queries/devops-integration';
+import { AlmSettingsInstance } from '../../../types/alm-settings';
 import { Component } from '../../../types/types';
 import { LoggedInUser } from '../../../types/users';
 import { InlineSnippet } from '../components/InlineSnippet';
@@ -40,11 +41,11 @@ export interface SecretStepProps {
   baseUrl: string;
   component: Component;
   currentUser: LoggedInUser;
-  projectBinding?: ProjectAlmBindingResponse;
 }
 
 export default function SecretStep(props: SecretStepProps) {
-  const { almBinding, baseUrl, component, currentUser, projectBinding } = props;
+  const { almBinding, baseUrl, component, currentUser } = props;
+  const { data: projectBinding } = useProjectBindingQuery(component.key);
 
   return (
     <>
index 16c3e2e9c87e2b92bfcd3d7b78ce1445588d4c24..dff47e4a85e1bbc8b033e01884dfbcc0ca338eee 100644 (file)
 import userEvent from '@testing-library/user-event';
 import React from 'react';
 import selectEvent from 'react-select-event';
+import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
 import UserTokensMock from '../../../../api/mocks/UserTokensMock';
-import {
-  mockAlmSettingsInstance,
-  mockProjectAlmBindingResponse,
-} from '../../../../helpers/mocks/alm-settings';
+import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings';
 import { mockComponent } from '../../../../helpers/mocks/component';
 import { mockLanguage, mockLoggedInUser } from '../../../../helpers/testMocks';
 import { RenderContext, renderApp } from '../../../../helpers/testReactTestingUtils';
@@ -47,9 +45,11 @@ jest.mock('../../../../api/settings', () => ({
 }));
 
 const tokenMock = new UserTokensMock();
+const almMock = new AlmSettingsServiceMock();
 
 afterEach(() => {
   tokenMock.reset();
+  almMock.reset();
 });
 
 const ui = {
@@ -134,12 +134,17 @@ it('should generate/delete a new token or use existing one', async () => {
 
 it('navigates between steps', async () => {
   const user = userEvent.setup();
+  almMock.handleSetProjectBinding(AlmKeys.GitHub, {
+    almSetting: 'my-project',
+    project: 'my-project',
+    repository: 'my-project',
+    monorepo: true,
+  });
   renderGithubActionTutorial({
     almBinding: mockAlmSettingsInstance({
       alm: AlmKeys.GitHub,
       url: 'http://localhost/qube',
     }),
-    projectBinding: mockProjectAlmBindingResponse({ alm: AlmKeys.GitHub }),
   });
 
   // If project is bound, link to repo is visible
index a97684627988bad14d5f007d39e3f8f046c4647d..0862d2bd431e2a54e4f8758dc38cfd81992ce51a 100644 (file)
@@ -23,11 +23,8 @@ import withAvailableFeatures, {
   WithAvailableFeaturesProps,
 } from '../../../app/components/available-features/withAvailableFeatures';
 import { translate } from '../../../helpers/l10n';
-import {
-  AlmKeys,
-  AlmSettingsInstance,
-  ProjectAlmBindingResponse,
-} from '../../../types/alm-settings';
+import { useProjectBindingQuery } from '../../../queries/devops-integration';
+import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
 import { Feature } from '../../../types/features';
 import { Component } from '../../../types/types';
 import AllSet from '../components/AllSet';
@@ -42,17 +39,21 @@ export interface JenkinsTutorialProps extends WithAvailableFeaturesProps {
   almBinding?: AlmSettingsInstance;
   baseUrl: string;
   component: Component;
-  projectBinding?: ProjectAlmBindingResponse;
   willRefreshAutomatically?: boolean;
 }
 
 export function JenkinsTutorial(props: JenkinsTutorialProps) {
-  const { almBinding, baseUrl, component, projectBinding, willRefreshAutomatically } = props;
+  const { almBinding, baseUrl, component, willRefreshAutomatically } = props;
+  const { data: projectBinding } = useProjectBindingQuery(component.key);
   const hasSelectAlmStep = projectBinding?.alm === undefined;
   const branchSupportEnabled = props.hasFeature(Feature.BranchSupport);
   const [alm, setAlm] = React.useState<AlmKeys | undefined>(projectBinding?.alm);
   const [done, setDone] = React.useState(false);
 
+  React.useEffect(() => {
+    setAlm(projectBinding?.alm);
+  }, [projectBinding]);
+
   return (
     <>
       <Title>{translate('onboarding.tutorial.with.jenkins.title')}</Title>
index 17a01755dc3110f578b6e60102be49ad4019c9e1..11a1e848230c46d1e4d445194b2d1f9f6a68f062 100644 (file)
@@ -149,7 +149,7 @@ export default function MultiBranchPipelineStep(props: MultiBranchPipelineStepPr
                 </ListItem>
                 <ListItem>
                   {almBinding !== undefined &&
-                  projectBinding !== undefined &&
+                  projectBinding != null &&
                   buildGithubLink(almBinding, projectBinding) !== null ? (
                     <LabelValuePair
                       translationKey="onboarding.tutorial.with.jenkins.multi_branch_pipeline.step2.github.repo_url"
index d68683744d3050dad22db6286201b6a148c50649..6801ae72acd6df8ccad7753c68d01327d4ed94bb 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { waitFor } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import React from 'react';
+import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
 import UserTokensMock from '../../../../api/mocks/UserTokensMock';
 import { mockComponent } from '../../../../helpers/mocks/component';
 import { mockLanguage } from '../../../../helpers/testMocks';
@@ -41,9 +43,11 @@ jest.mock('../../../../api/settings', () => ({
 }));
 
 const tokenMock = new UserTokensMock();
+const almMock = new AlmSettingsServiceMock();
 
 afterEach(() => {
   tokenMock.reset();
+  almMock.reset();
 });
 
 const ui = {
@@ -169,21 +173,21 @@ it.each([AlmKeys.GitHub, AlmKeys.BitbucketCloud])(
   '%s: completes tutorial with bound alm and project',
   async (alm: AlmKeys) => {
     const user = userEvent.setup();
+    await almMock.handleSetProjectBinding(alm, {
+      almSetting: 'my-project',
+      project: 'my-project',
+      repository: 'my-project',
+      monorepo: true,
+    });
     renderJenkinsTutorial({
       almBinding: {
         alm,
         url: 'http://localhost/qube',
         key: 'my-project',
       },
-      projectBinding: {
-        alm,
-        key: 'my-project',
-        repository: 'my-project',
-        monorepo: true,
-      },
     });
 
-    expect(ui.devopsPlatformTitle.query()).not.toBeInTheDocument();
+    await waitFor(() => expect(ui.devopsPlatformTitle.query()).not.toBeInTheDocument());
 
     expect(ui.webhookAlmLink(alm).get()).toBeInTheDocument();
     await user.click(ui.mavenBuildButton.get());
diff --git a/server/sonar-web/src/main/js/queries/devops-integration.ts b/server/sonar-web/src/main/js/queries/devops-integration.ts
new file mode 100644 (file)
index 0000000..5fc783d
--- /dev/null
@@ -0,0 +1,160 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { UseQueryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useLocation } from 'react-router-dom';
+import {
+  deleteProjectAlmBinding,
+  getProjectAlmBinding,
+  setProjectAzureBinding,
+  setProjectBitbucketBinding,
+  setProjectBitbucketCloudBinding,
+  setProjectGithubBinding,
+  setProjectGitlabBinding,
+} from '../api/alm-settings';
+import { HttpStatus } from '../helpers/request';
+import { AlmKeys, ProjectAlmBindingParams, ProjectAlmBindingResponse } from '../types/alm-settings';
+
+function useProjectKeyFromLocation() {
+  const location = useLocation();
+  const search = new URLSearchParams(location.search);
+  const id = search.get('id');
+  return id as string;
+}
+
+export function useProjectBindingQuery<T = ProjectAlmBindingResponse>(
+  project?: string,
+  options?: UseQueryOptions<
+    ProjectAlmBindingResponse,
+    unknown,
+    T,
+    ['devops_integration', string, 'binding']
+  >
+) {
+  const keyFromUrl = useProjectKeyFromLocation();
+
+  const projectKey = project ?? keyFromUrl;
+
+  return useQuery(
+    ['devops_integration', projectKey, 'binding'],
+    ({ queryKey: [_, key] }) =>
+      getProjectAlmBinding(key).catch((e: Response) => {
+        if (e.status === HttpStatus.NotFound) {
+          return null;
+        }
+        throw e;
+      }),
+    {
+      staleTime: 60_000,
+      retry: false,
+      ...options,
+    }
+  );
+}
+
+export function useIsGitHubProjectQuery(project?: string) {
+  return useProjectBindingQuery(project, { select: (data) => data?.alm === AlmKeys.GitHub });
+}
+
+export function useDeleteProjectAlmBindingMutation(project?: string) {
+  const keyFromUrl = useProjectKeyFromLocation();
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: () => deleteProjectAlmBinding(project ?? keyFromUrl),
+    onSuccess: () => {
+      client.invalidateQueries(['devops_integration', project ?? keyFromUrl, 'binding']);
+    },
+  });
+}
+
+const getSetProjectBindingFn = (data: SetBindingParams) => {
+  const { alm, almSetting, project, monorepo, slug, repository, summaryCommentEnabled } = data;
+  switch (alm) {
+    case AlmKeys.Azure: {
+      return setProjectAzureBinding({
+        almSetting,
+        project,
+        projectName: slug,
+        repositoryName: repository,
+        monorepo,
+      });
+    }
+    case AlmKeys.BitbucketServer: {
+      return setProjectBitbucketBinding({
+        almSetting,
+        project,
+        repository,
+        slug,
+        monorepo,
+      });
+    }
+    case AlmKeys.BitbucketCloud: {
+      return setProjectBitbucketCloudBinding({
+        almSetting,
+        project,
+        repository,
+        monorepo,
+      });
+    }
+    case AlmKeys.GitHub: {
+      return setProjectGithubBinding({
+        almSetting,
+        project,
+        repository,
+        summaryCommentEnabled,
+        monorepo,
+      });
+    }
+
+    case AlmKeys.GitLab: {
+      return setProjectGitlabBinding({
+        almSetting,
+        project,
+        repository,
+        monorepo,
+      });
+    }
+
+    default:
+      return Promise.reject();
+  }
+};
+
+type SetBindingParams = ProjectAlmBindingParams & {
+  repository: string;
+} & (
+    | { alm: AlmKeys.Azure | AlmKeys.BitbucketServer; slug: string; summaryCommentEnabled?: never }
+    | { alm: AlmKeys.GitHub; summaryCommentEnabled: boolean; slug?: never }
+    | {
+        alm: Exclude<AlmKeys, AlmKeys.Azure | AlmKeys.GitHub | AlmKeys.BitbucketServer>;
+        slug?: never;
+        summaryCommentEnabled?: never;
+      }
+  );
+
+export function useSetProjectBindingMutation() {
+  const client = useQueryClient();
+  return useMutation({
+    mutationFn: (data: SetBindingParams) => getSetProjectBindingFn(data),
+    onSuccess: (_, variables) => {
+      client.invalidateQueries(['devops_integration', variables.project, 'binding']);
+    },
+  });
+}
index af998ce69d2928e8a2759ac9bee18d024064e5f1..5cb658de7c6e21f8c14d5ae5305150c0a6d97bb8 100644 (file)
@@ -17,7 +17,6 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { ProjectAlmBindingResponse } from './alm-settings';
 import { Component, LightComponent } from './types';
 
 export enum Visibility {
@@ -101,5 +100,4 @@ export interface ComponentContextShape {
   isPending?: boolean;
   onComponentChange: (changes: Partial<Component>) => void;
   fetchComponent: () => Promise<void>;
-  projectBinding?: ProjectAlmBindingResponse;
 }