From 789ebe6feca11b12c4d4505bc7a9c218b1993d89 Mon Sep 17 00:00:00 2001 From: Viktor Vorona Date: Thu, 24 Aug 2023 10:25:22 +0200 Subject: [PATCH] SONAR-19932 Have always actual projectBinding due to react-query --- .../js/api/mocks/AlmSettingsServiceMock.ts | 4 + .../js/app/components/ComponentContainer.tsx | 20 +- .../__tests__/ComponentContainer-test.tsx | 35 -- .../components/nav/component/ComponentNav.tsx | 11 +- .../app/components/nav/component/Header.tsx | 6 +- .../nav/component/__tests__/Header-test.tsx | 19 +- .../branch-like/BranchHelpTooltip.tsx | 13 +- .../branch-like/BranchLikeNavigation.tsx | 6 - .../apps/overview/branches/BranchOverview.tsx | 5 +- .../branches/BranchOverviewRenderer.tsx | 4 - .../branches/FirstAnalysisNextStepsNotif.tsx | 12 +- .../branches/__tests__/BranchOverview-it.tsx | 7 + .../main/js/apps/overview/components/App.tsx | 5 +- .../project/components/PageHeader.tsx | 2 +- .../components/PermissionsProjectApp.tsx | 65 +-- .../__tests__/PermissionsProject-it.tsx | 91 ++-- .../PRDecorationBinding.tsx | 394 +++++++----------- .../__tests__/PRDecorationBinding-it.tsx | 15 +- .../tutorials/components/TutorialsApp.tsx | 10 +- .../components/permissions/AllHoldersList.tsx | 13 +- .../js/components/permissions/HoldersList.tsx | 62 +-- .../tutorials/TutorialSelection.tsx | 144 +++---- .../tutorials/TutorialSelectionRenderer.tsx | 5 +- .../__tests__/TutorialSelection-it.tsx | 38 +- .../BitbucketPipelinesTutorial.tsx | 24 +- .../RepositoryVariables.tsx | 7 +- .../BitbucketPipelinesTutorial-it.tsx | 18 +- .../github-action/GitHubActionTutorial.tsx | 19 +- .../tutorials/github-action/SecretStep.tsx | 7 +- .../__tests__/GithubActionTutorial-it.tsx | 15 +- .../tutorials/jenkins/JenkinsTutorial.tsx | 15 +- .../jenkins/MultiBranchPipelineStep.tsx | 2 +- .../jenkins/__tests__/JenkinsTutorial-it.tsx | 18 +- .../src/main/js/queries/devops-integration.ts | 160 +++++++ .../sonar-web/src/main/js/types/component.ts | 2 - 35 files changed, 642 insertions(+), 631 deletions(-) create mode 100644 server/sonar-web/src/main/js/queries/devops-integration.ts diff --git a/server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts index 0291697e4eb..01c4735096e 100644 --- a/server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/AlmSettingsServiceMock.ts @@ -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); diff --git a/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx b/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx index 2fde9f19ab0..bba0da8a471 100644 --- a/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx @@ -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 { 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 { return ; } - 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 { currentTask={currentTask} isInProgress={isInProgress} isPending={isPending} - projectBinding={projectBinding} projectBindingErrors={projectBindingErrors} /> )} @@ -370,7 +357,6 @@ export class ComponentContainer extends React.PureComponent { isPending, onComponentChange: this.handleComponentChange, fetchComponent: this.fetchComponent, - projectBinding, }} > diff --git a/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx b/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx index 48bf7483773..6b53e8980df 100644 --- a/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx +++ b/server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx @@ -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>); - - jest - .mocked(getComponentData) - .mockResolvedValueOnce({ component } as unknown as Awaited>) - .mockResolvedValueOnce({ component } as unknown as Awaited< - ReturnType - >); - - jest - .mocked(getProjectAlmBinding) - .mockResolvedValueOnce(undefined as unknown as Awaited>) - .mockResolvedValueOnce({ - alm: AlmKeys.GitHub, - key: 'foo', - } as unknown as Awaited>); - - 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); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx index cf8019d236a..e25d86d2a97 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx @@ -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` }} >
-
+
- +
); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Header-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Header-test.tsx index 4b515761886..0d22beda1d8 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Header-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Header-test.tsx @@ -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, - }), }, [] ); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchHelpTooltip.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchHelpTooltip.tsx index 160b46a5ccf..0c42dd26b86 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchHelpTooltip.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchHelpTooltip.tsx @@ -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 = ; + 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 ( diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx index cef93cc97d2..8c0cb2c306a 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx @@ -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 { }; 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 { metrics={metrics} onGraphChange={this.handleGraphChange} period={period} - projectBinding={projectBinding} projectIsEmpty={projectIsEmpty} qgStatuses={qgStatuses} /> diff --git a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx index 06853bf92d6..9a7654eccea 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/BranchOverviewRenderer.tsx @@ -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} /> diff --git a/server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx b/server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx index 8f533485569..9f4c0f2838c 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/FirstAnalysisNextStepsNotif.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx index 93f8b78d687..ab51129a308 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/BranchOverview-it.tsx @@ -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(); diff --git a/server/sonar-web/src/main/js/apps/overview/components/App.tsx b/server/sonar-web/src/main/js/apps/overview/components/App.tsx index dc8d50d745a..f1edda56c3f 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/App.tsx +++ b/server/sonar-web/src/main/js/apps/overview/components/App.tsx @@ -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} /> )} diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx index 0b452177aca..d6e3ecee691 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/PageHeader.tsx @@ -31,7 +31,7 @@ import ApplyTemplate from './ApplyTemplate'; interface Props { component: Component; - isGitHubProject: boolean; + isGitHubProject?: boolean; loadHolders: () => void; loading: boolean; } diff --git a/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx b/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx index 1f291c94188..9def5136bc4 100644 --- a/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx +++ b/server/sonar-web/src/main/js/apps/permissions/project/components/PermissionsProjectApp.tsx @@ -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 { }; render() { - const { component, projectBinding } = this.props; + const { component } = this.props; const { filter, groups, @@ -342,46 +342,51 @@ class PermissionsProjectApp extends React.PureComponent { order = without(order, Permissions.Browse, Permissions.CodeViewer); } const permissions = convertToPermissionDefinitions(order, 'projects_role'); - const isGitHubProject = projectBinding?.alm === AlmKeys.GitHub; return (
- -
- - {({ data: githubProvisioningStatus, isFetching }) => ( - + {({ data: isGitHubProject }) => ( + <> + - )} - - - {disclaimer && ( - +
+ + {({ data: githubProvisioningStatus, isFetching }) => ( + + )} + + + {disclaimer && ( + + )} +
+ )} -
+ + { 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 = {}, componentContextOverride: Partial = {} ) { - 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, + } + ); } diff --git a/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBinding.tsx b/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBinding.tsx index aaf2ec9d983..2e3510abe2b 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBinding.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/PRDecorationBinding.tsx @@ -19,21 +19,14 @@ */ 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 { - 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(cloneDeep(INITIAL_FORM_DATA)); + const [instances, setInstances] = React.useState([]); + 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 { - 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 - ): Promise { + ): Promise => { 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 ( - - ); - } + return ( + + ); } export default withCurrentUserContext(PRDecorationBinding); diff --git a/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBinding-it.tsx b/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBinding-it.tsx index e37aff3ec47..bfe7a890fcd 100644 --- a/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBinding-it.tsx +++ b/server/sonar-web/src/main/js/apps/settings/components/pullRequestDecorationBinding/__tests__/PRDecorationBinding-it.tsx @@ -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(); } diff --git a/server/sonar-web/src/main/js/apps/tutorials/components/TutorialsApp.tsx b/server/sonar-web/src/main/js/apps/tutorials/components/TutorialsApp.tsx index bff4b922f50..d1aa1c6fcde 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/components/TutorialsApp.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/components/TutorialsApp.tsx @@ -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 ( - + ); diff --git a/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx b/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx index 3f80139f227..0d60b2fa438 100644 --- a/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx +++ b/server/sonar-web/src/main/js/components/permissions/AllHoldersList.tsx @@ -47,7 +47,6 @@ interface Props { selectedPermission?: string; onSelectPermission?: (permissions?: string) => void; loading?: boolean; - isGitHubProject?: boolean; } export default class AllHoldersList extends React.PureComponent { @@ -88,23 +87,13 @@ export default class AllHoldersList extends React.PureComponent { }; 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 ( <> { } renderItem(item: PermissionUser | PermissionGroup, permissions: PermissionDefinitions) { - const { isGitHubProject, selectedPermission, isComponentPrivate } = this.props; + const { selectedPermission, isComponentPrivate } = this.props; return ( - - {({ data: githubProvisioningStatus }) => ( - <> - {this.isPermissionUser(item) ? ( - - ) : ( - + + {({ data: isGitHubProject }) => ( + + {({ data: githubProvisioningStatus }) => ( + <> + {this.isPermissionUser(item) ? ( + + ) : ( + + )} + )} - + )} ); diff --git a/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx b/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx index 3cb64f78607..574945833ac 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx @@ -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 { - 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(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 ( - - ); - } + }; + + fetchAlmBindings().catch(() => {}); + }, [component.key, projectBinding]); + + const selectedTutorial: TutorialModes | undefined = location.query?.selectedTutorial; + + return ( + + ); } export default withRouter(TutorialSelection); diff --git a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx index 5ce4cf487ca..a3d6ad33432 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx @@ -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} /> )} diff --git a/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx b/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx index 511fadce2f0..016d2b4398d 100644 --- a/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx @@ -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 () => { diff --git a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/BitbucketPipelinesTutorial.tsx b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/BitbucketPipelinesTutorial.tsx index 31c33edbc17..6d0e756039f 100644 --- a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/BitbucketPipelinesTutorial.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/BitbucketPipelinesTutorial.tsx @@ -20,11 +20,7 @@ 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(false); return ( @@ -75,7 +63,6 @@ export default function BitbucketPipelinesTutorial(props: BitbucketPipelinesTuto baseUrl={baseUrl} component={component} currentUser={currentUser} - projectBinding={projectBinding} /> @@ -101,7 +88,10 @@ export default function BitbucketPipelinesTutorial(props: BitbucketPipelinesTuto {done && ( <> - + )} diff --git a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/RepositoryVariables.tsx b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/RepositoryVariables.tsx index 33c096a9092..c13825a10ca 100644 --- a/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/RepositoryVariables.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/bitbucket-pipelines/RepositoryVariables.tsx @@ -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 ( <> ({ })); 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 diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx index e7f00e98a9d..8b37a1573c9 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/GitHubActionTutorial.tsx @@ -20,11 +20,7 @@ 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(false); - const { - almBinding, - baseUrl, - currentUser, - component, - projectBinding, - mainBranchName, - willRefreshAutomatically, - } = props; + const { almBinding, baseUrl, currentUser, component, mainBranchName, willRefreshAutomatically } = + props; return ( <> {translate('onboarding.tutorial.with.github_ci.title')} @@ -66,7 +54,6 @@ export default function GitHubActionTutorial(props: GitHubActionTutorialProps) { baseUrl={baseUrl} component={component} currentUser={currentUser} - projectBinding={projectBinding} /> diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx index ff606173ee8..28b7fb42f75 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/SecretStep.tsx @@ -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 ( <> diff --git a/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/GithubActionTutorial-it.tsx b/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/GithubActionTutorial-it.tsx index 16c3e2e9c87..dff47e4a85e 100644 --- a/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/GithubActionTutorial-it.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/github-action/__tests__/GithubActionTutorial-it.tsx @@ -21,11 +21,9 @@ 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 diff --git a/server/sonar-web/src/main/js/components/tutorials/jenkins/JenkinsTutorial.tsx b/server/sonar-web/src/main/js/components/tutorials/jenkins/JenkinsTutorial.tsx index a9768462798..0862d2bd431 100644 --- a/server/sonar-web/src/main/js/components/tutorials/jenkins/JenkinsTutorial.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/jenkins/JenkinsTutorial.tsx @@ -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(projectBinding?.alm); const [done, setDone] = React.useState(false); + React.useEffect(() => { + setAlm(projectBinding?.alm); + }, [projectBinding]); + return ( <> {translate('onboarding.tutorial.with.jenkins.title')} diff --git a/server/sonar-web/src/main/js/components/tutorials/jenkins/MultiBranchPipelineStep.tsx b/server/sonar-web/src/main/js/components/tutorials/jenkins/MultiBranchPipelineStep.tsx index 17a01755dc3..11a1e848230 100644 --- a/server/sonar-web/src/main/js/components/tutorials/jenkins/MultiBranchPipelineStep.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/jenkins/MultiBranchPipelineStep.tsx @@ -149,7 +149,7 @@ export default function MultiBranchPipelineStep(props: MultiBranchPipelineStepPr {almBinding !== undefined && - projectBinding !== undefined && + projectBinding != null && buildGithubLink(almBinding, projectBinding) !== null ? ( ({ })); 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 index 00000000000..5fc783d9029 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/devops-integration.ts @@ -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( + 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; + 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']); + }, + }); +} diff --git a/server/sonar-web/src/main/js/types/component.ts b/server/sonar-web/src/main/js/types/component.ts index af998ce69d2..5cb658de7c6 100644 --- a/server/sonar-web/src/main/js/types/component.ts +++ b/server/sonar-web/src/main/js/types/component.ts @@ -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) => void; fetchComponent: () => Promise; - projectBinding?: ProjectAlmBindingResponse; } -- 2.39.5