diff options
107 files changed, 1825 insertions, 2291 deletions
diff --git a/server/sonar-web/src/main/js/api/mocks/BranchesServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/BranchesServiceMock.ts index fa6847c1382..af60faa5205 100644 --- a/server/sonar-web/src/main/js/api/mocks/BranchesServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/BranchesServiceMock.ts @@ -32,7 +32,7 @@ import { jest.mock('../branches'); const defaultBranches: Branch[] = [ - mockBranch({ isMain: true, name: 'master', status: { qualityGateStatus: 'OK' } }), + mockBranch({ isMain: true, name: 'main', status: { qualityGateStatus: 'OK' } }), mockBranch({ excludedFromPurge: false, name: 'delete-branch', @@ -113,10 +113,19 @@ export default class BranchesServiceMock { this.branches = []; }; + emptyBranchesAndPullRequest = () => { + this.branches = []; + this.pullRequests = []; + }; + addBranch = (branch: Branch) => { this.branches.push(branch); }; + addPullRequest = (branch: PullRequest) => { + this.pullRequests.push(branch); + }; + reset = () => { this.branches = cloneDeep(defaultBranches); this.pullRequests = cloneDeep(defaultPullRequests); diff --git a/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts index c5085c5ee96..a2086d31ab7 100644 --- a/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts @@ -137,7 +137,7 @@ export default class SecurityHotspotServiceMock { branch?: string; } ) => { - if (data?.branch === 'b1') { + if (data?.branch === 'normal-branch') { return this.reply({ paging: mockPaging(), hotspots: [ @@ -198,7 +198,7 @@ export default class SecurityHotspotServiceMock { inNewCodePeriod?: boolean; branch?: string; }) => { - if (data?.branch === 'b1') { + if (data?.branch === 'normal-branch') { return this.reply({ paging: mockPaging({ pageIndex: 1, pageSize: data.ps, total: 2 }), hotspots: [ 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 0f01470dd74..d9c9c9e9864 100644 --- a/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/ComponentContainer.tsx @@ -22,17 +22,10 @@ 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 { getBranches, getPullRequests } from '../../api/branches'; -import { getAnalysisStatus, getTasksForComponent } from '../../api/ce'; +import { getTasksForComponent } from '../../api/ce'; import { getComponentData } from '../../api/components'; import { getComponentNavigation } from '../../api/navigation'; import { Location, Router, withRouter } from '../../components/hoc/withRouter'; -import { - getBranchLikeQuery, - isBranch, - isMainBranch, - isPullRequest, -} from '../../helpers/branch-like'; import { translateWithParameters } from '../../helpers/l10n'; import { HttpStatus } from '../../helpers/request'; import { getPortfolioUrl } from '../../helpers/urls'; @@ -40,30 +33,25 @@ import { ProjectAlmBindingConfigurationErrors, ProjectAlmBindingResponse, } from '../../types/alm-settings'; -import { BranchLike } from '../../types/branch-like'; import { ComponentQualifier, isPortfolioLike } from '../../types/component'; import { Feature } from '../../types/features'; -import { Task, TaskStatuses, TaskTypes, TaskWarning } from '../../types/tasks'; -import { Component, Status } from '../../types/types'; +import { Task, TaskStatuses, TaskTypes } from '../../types/tasks'; +import { Component } from '../../types/types'; import handleRequiredAuthorization from '../utils/handleRequiredAuthorization'; import ComponentContainerNotFound from './ComponentContainerNotFound'; import withAvailableFeatures, { WithAvailableFeaturesProps, } from './available-features/withAvailableFeatures'; -import withBranchStatusActions from './branch-status/withBranchStatusActions'; import { ComponentContext } from './componentContext/ComponentContext'; import PageUnavailableDueToIndexation from './indexation/PageUnavailableDueToIndexation'; import ComponentNav from './nav/component/ComponentNav'; interface Props extends WithAvailableFeaturesProps { location: Location; - updateBranchStatus: (branchLike: BranchLike, component: string, status: Status) => void; router: Router; } interface State { - branchLike?: BranchLike; - branchLikes: BranchLike[]; component?: Component; currentTask?: Task; isPending: boolean; @@ -71,7 +59,6 @@ interface State { projectBinding?: ProjectAlmBindingResponse; projectBindingErrors?: ProjectAlmBindingConfigurationErrors; tasksInProgress?: Task[]; - warnings: TaskWarning[]; } const FETCH_STATUS_WAIT_TIME = 3000; @@ -79,7 +66,7 @@ const FETCH_STATUS_WAIT_TIME = 3000; export class ComponentContainer extends React.PureComponent<Props, State> { watchStatusTimer?: number; mounted = false; - state: State = { branchLikes: [], isPending: false, loading: true, warnings: [] }; + state: State = { isPending: false, loading: true }; componentDidMount() { this.mounted = true; @@ -135,8 +122,6 @@ export class ComponentContainer extends React.PureComponent<Props, State> { this.props.router.replace(getPortfolioUrl(componentWithQualifier.key)); } - const { branchLike, branchLikes } = await this.fetchBranches(componentWithQualifier); - let projectBinding; if (componentWithQualifier.qualifier === ComponentQualifier.Project) { projectBinding = await getProjectAlmBinding(key).catch(() => undefined); @@ -144,59 +129,25 @@ export class ComponentContainer extends React.PureComponent<Props, State> { if (this.mounted) { this.setState({ - branchLike, - branchLikes, component: componentWithQualifier, projectBinding, loading: false, }); this.fetchStatus(componentWithQualifier.key); - this.fetchWarnings(componentWithQualifier, branchLike); this.fetchProjectBindingErrors(componentWithQualifier); } }; - fetchBranches = async (componentWithQualifier: Component) => { - const { hasFeature } = this.props; - - const breadcrumb = componentWithQualifier.breadcrumbs.find(({ qualifier }) => { - return ([ComponentQualifier.Application, ComponentQualifier.Project] as string[]).includes( - qualifier - ); - }); - - let branchLike = undefined; - let branchLikes: BranchLike[] = []; - - if (breadcrumb) { - const { key } = breadcrumb; - const [branches, pullRequests] = await Promise.all([ - getBranches(key), - !hasFeature(Feature.BranchSupport) || - breadcrumb.qualifier === ComponentQualifier.Application - ? Promise.resolve([]) - : getPullRequests(key), - ]); - - branchLikes = [...branches, ...pullRequests]; - branchLike = this.getCurrentBranchLike(branchLikes); - - this.registerBranchStatuses(branchLikes, componentWithQualifier); - } - - return { branchLike, branchLikes }; - }; - fetchStatus = (componentKey: string) => { getTasksForComponent(componentKey).then( ({ current, queue }) => { if (this.mounted) { let shouldFetchComponent = false; this.setState( - ({ branchLike, component, currentTask, tasksInProgress }) => { - const newCurrentTask = this.getCurrentTask(current, branchLike); - const pendingTasks = this.getPendingTasksForBranchLike(queue, branchLike); + ({ component, currentTask, tasksInProgress }) => { + const newCurrentTask = this.getCurrentTask(current); + const pendingTasks = this.getPendingTasksForBranchLike(queue); const newTasksInProgress = this.getInProgressTasks(pendingTasks); shouldFetchComponent = this.computeShouldFetchComponent( @@ -235,20 +186,6 @@ export class ComponentContainer extends React.PureComponent<Props, State> { ); }; - fetchWarnings = (component: Component, branchLike?: BranchLike) => { - if (component.qualifier === ComponentQualifier.Project) { - getAnalysisStatus({ - component: component.key, - ...getBranchLikeQuery(branchLike), - }).then( - ({ component }) => { - this.setState({ warnings: component.warnings }); - }, - () => {} - ); - } - }; - fetchProjectBindingErrors = async (component: Component) => { if ( component.qualifier === ComponentQualifier.Project && @@ -269,27 +206,18 @@ export class ComponentContainer extends React.PureComponent<Props, State> { qualifier: component.breadcrumbs[component.breadcrumbs.length - 1].qualifier, }); - getCurrentBranchLike = (branchLikes: BranchLike[]) => { - const { query } = this.props.location; - return query.pullRequest - ? branchLikes.find((b) => isPullRequest(b) && b.key === query.pullRequest) - : branchLikes.find((b) => isBranch(b) && (query.branch ? b.name === query.branch : b.isMain)); - }; - - getCurrentTask = (current: Task, branchLike?: BranchLike) => { + getCurrentTask = (current: Task) => { if (!current || !this.isReportRelatedTask(current)) { return undefined; } - return current.status === TaskStatuses.Failed || this.isSameBranch(current, branchLike) + return current.status === TaskStatuses.Failed || this.isSameBranch(current) ? current : undefined; }; - getPendingTasksForBranchLike = (pendingTasks: Task[], branchLike?: BranchLike) => { - return pendingTasks.filter( - (task) => this.isReportRelatedTask(task) && this.isSameBranch(task, branchLike) - ); + getPendingTasksForBranchLike = (pendingTasks: Task[]) => { + return pendingTasks.filter((task) => this.isReportRelatedTask(task) && this.isSameBranch(task)); }; getInProgressTasks = (pendingTasks: Task[]) => { @@ -346,31 +274,19 @@ export class ComponentContainer extends React.PureComponent<Props, State> { ); }; - isSameBranch = (task: Pick<Task, 'branch' | 'pullRequest'>, branchLike?: BranchLike) => { - if (branchLike) { - if (isMainBranch(branchLike)) { - return (!task.pullRequest && !task.branch) || branchLike.name === task.branch; - } - if (isPullRequest(branchLike)) { - return branchLike.key === task.pullRequest; - } - if (isBranch(branchLike)) { - return branchLike.name === task.branch; - } - } - return !task.branch && !task.pullRequest; - }; + isSameBranch = (task: Pick<Task, 'branch' | 'pullRequest'>) => { + const { branch, pullRequest } = this.props.location.query; - registerBranchStatuses = (branchLikes: BranchLike[], component: Component) => { - branchLikes.forEach((branchLike) => { - if (branchLike.status) { - this.props.updateBranchStatus( - branchLike, - component.key, - branchLike.status.qualityGateStatus - ); - } - }); + if (!pullRequest && !branch) { + return !task.branch && !task.pullRequest; + } + if (pullRequest) { + return pullRequest === task.pullRequest; + } + if (branch) { + return branch === task.branch; + } + return false; }; handleComponentChange = (changes: Partial<Component>) => { @@ -385,33 +301,6 @@ export class ComponentContainer extends React.PureComponent<Props, State> { } }; - handleBranchesChange = () => { - const { router, location } = this.props; - const { component } = this.state; - - if (this.mounted && component) { - this.fetchBranches(component).then( - ({ branchLike, branchLikes }) => { - if (this.mounted) { - this.setState({ branchLike, branchLikes }); - - if (branchLike === undefined) { - router.replace({ query: { ...location.query, branch: undefined } }); - } - } - }, - () => {} - ); - } - }; - - handleWarningDismiss = () => { - const { component } = this.state; - if (component !== undefined) { - this.fetchWarnings(component); - } - }; - render() { const { component, loading } = this.state; @@ -423,16 +312,8 @@ export class ComponentContainer extends React.PureComponent<Props, State> { return <PageUnavailableDueToIndexation component={component} />; } - const { - branchLike, - branchLikes, - currentTask, - isPending, - projectBinding, - projectBindingErrors, - tasksInProgress, - warnings, - } = this.state; + const { currentTask, isPending, projectBinding, projectBindingErrors, tasksInProgress } = + this.state; const isInProgress = tasksInProgress && tasksInProgress.length > 0; return ( @@ -449,17 +330,12 @@ export class ComponentContainer extends React.PureComponent<Props, State> { component.qualifier ) && ( <ComponentNav - branchLikes={branchLikes} component={component} - currentBranchLike={branchLike} currentTask={currentTask} - currentTaskOnSameBranch={currentTask && this.isSameBranch(currentTask, branchLike)} isInProgress={isInProgress} isPending={isPending} - onWarningDismiss={this.handleWarningDismiss} projectBinding={projectBinding} projectBindingErrors={projectBindingErrors} - warnings={warnings} /> )} {loading ? ( @@ -469,12 +345,9 @@ export class ComponentContainer extends React.PureComponent<Props, State> { ) : ( <ComponentContext.Provider value={{ - branchLike, - branchLikes, component, isInProgress, isPending, - onBranchesChange: this.handleBranchesChange, onComponentChange: this.handleComponentChange, projectBinding, }} @@ -487,4 +360,4 @@ export class ComponentContainer extends React.PureComponent<Props, State> { } } -export default withRouter(withAvailableFeatures(withBranchStatusActions(ComponentContainer))); +export default withRouter(withAvailableFeatures(ComponentContainer)); diff --git a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx index b1ed60e0ce4..05c5c8148ca 100644 --- a/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx +++ b/server/sonar-web/src/main/js/app/components/GlobalContainer.tsx @@ -29,7 +29,6 @@ import Workspace from '../../components/workspace/Workspace'; import GlobalFooter from './GlobalFooter'; import StartupModal from './StartupModal'; import SystemAnnouncement from './SystemAnnouncement'; -import BranchStatusContextProvider from './branch-status/BranchStatusContextProvider'; import IndexationContextProvider from './indexation/IndexationContextProvider'; import IndexationNotification from './indexation/IndexationNotification'; import LanguagesContextProvider from './languages/LanguagesContextProvider'; @@ -68,21 +67,19 @@ export default function GlobalContainer() { id="container" > <div className="page-container"> - <BranchStatusContextProvider> - <Workspace> - <IndexationContextProvider> - <LanguagesContextProvider> - <MetricsContextProvider> - <SystemAnnouncement /> - <IndexationNotification /> - <UpdateNotification dismissable /> - <GlobalNav location={location} /> - <Outlet /> - </MetricsContextProvider> - </LanguagesContextProvider> - </IndexationContextProvider> - </Workspace> - </BranchStatusContextProvider> + <Workspace> + <IndexationContextProvider> + <LanguagesContextProvider> + <MetricsContextProvider> + <SystemAnnouncement /> + <IndexationNotification /> + <UpdateNotification dismissable /> + <GlobalNav location={location} /> + <Outlet /> + </MetricsContextProvider> + </LanguagesContextProvider> + </IndexationContextProvider> + </Workspace> </div> <PromotionNotification /> </div> 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 27c68dc4db1..9faf1d9486b 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 @@ -21,7 +21,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { getProjectAlmBinding, validateProjectAlmBinding } from '../../../api/alm-settings'; import { getBranches, getPullRequests } from '../../../api/branches'; -import { getAnalysisStatus, getTasksForComponent } from '../../../api/ce'; +import { getTasksForComponent } from '../../../api/ce'; import { getComponentData } from '../../../api/components'; import { getComponentNavigation } from '../../../api/navigation'; import { mockProjectAlmBindingConfigurationErrors } from '../../../helpers/mocks/alm-settings'; @@ -97,7 +97,6 @@ afterEach(() => { it('changes component', () => { const wrapper = shallowRender(); wrapper.setState({ - branchLikes: [mockMainBranch()], component: { qualifier: ComponentQualifier.Project, visibility: Visibility.Public, @@ -147,42 +146,6 @@ it("doesn't load branches portfolio", async () => { }); }); -it('updates branches on change', async () => { - const updateBranchStatus = jest.fn(); - const wrapper = shallowRender({ - hasFeature: () => true, - location: mockLocation({ query: { id: 'portfolioKey' } }), - updateBranchStatus, - }); - wrapper.setState({ - branchLikes: [mockMainBranch()], - component: mockComponent({ - breadcrumbs: [{ key: 'projectKey', name: 'project', qualifier: ComponentQualifier.Project }], - }), - loading: false, - }); - wrapper.instance().handleBranchesChange(); - expect(getBranches).toHaveBeenCalledWith('projectKey'); - expect(getPullRequests).toHaveBeenCalledWith('projectKey'); - await waitAndUpdate(wrapper); - expect(updateBranchStatus).toHaveBeenCalledTimes(2); -}); - -it('sets main branch when current branch is not found', async () => { - const router = mockRouter(); - const wrapper = shallowRender({ - hasFeature: () => true, - location: mockLocation({ query: { id: 'portfolioKey', branch: 'any-branch' } }), - router, - }); - await waitAndUpdate(wrapper); - - wrapper.instance().handleBranchesChange(); - await waitAndUpdate(wrapper); - - expect(router.replace).toHaveBeenCalledWith({ query: { id: 'portfolioKey' } }); -}); - it('fetches status', async () => { (getComponentData as jest.Mock<any>).mockResolvedValueOnce({ component: {}, @@ -198,31 +161,29 @@ it('filters correctly the pending tasks for a main branch', () => { const component = wrapper.instance(); const mainBranch = mockMainBranch(); const branch3 = mockBranch({ name: 'branch-3' }); - const branch2 = mockBranch({ name: 'branch-2' }); const pullRequest = mockPullRequest(); expect(component.isSameBranch({})).toBe(true); - expect(component.isSameBranch({}, mainBranch)).toBe(true); - expect(component.isSameBranch({ branch: mainBranch.name }, mainBranch)).toBe(true); - expect(component.isSameBranch({}, branch3)).toBe(false); - expect(component.isSameBranch({ branch: branch3.name }, branch3)).toBe(true); - expect(component.isSameBranch({ branch: 'feature' }, branch2)).toBe(false); - expect(component.isSameBranch({ branch: 'branch-6.6' }, branch2)).toBe(false); - expect(component.isSameBranch({ branch: branch2.name }, branch2)).toBe(true); - expect(component.isSameBranch({ branch: 'branch-6.7' }, pullRequest)).toBe(false); - expect(component.isSameBranch({ pullRequest: pullRequest.key }, pullRequest)).toBe(true); + wrapper.setProps({ location: mockLocation({ query: { branch: mainBranch.name } }) }); + expect(component.isSameBranch({ branch: mainBranch.name })).toBe(true); + expect(component.isSameBranch({})).toBe(false); + wrapper.setProps({ location: mockLocation({ query: { branch: branch3.name } }) }); + expect(component.isSameBranch({ branch: branch3.name })).toBe(true); + wrapper.setProps({ location: mockLocation({ query: { pullRequest: pullRequest.key } }) }); + expect(component.isSameBranch({ pullRequest: pullRequest.key })).toBe(true); const currentTask = mockTask({ pullRequest: pullRequest.key, status: TaskStatuses.InProgress }); const failedTask = { ...currentTask, status: TaskStatuses.Failed }; const pendingTasks = [currentTask, mockTask({ branch: branch3.name }), mockTask()]; + expect(component.getCurrentTask(failedTask)).toBe(failedTask); + wrapper.setProps({ location: mockLocation({ query: {} }) }); expect(component.getCurrentTask(currentTask)).toBeUndefined(); - expect(component.getCurrentTask(failedTask, mainBranch)).toBe(failedTask); - expect(component.getCurrentTask(currentTask, mainBranch)).toBeUndefined(); - expect(component.getCurrentTask(currentTask, pullRequest)).toMatchObject(currentTask); - expect(component.getPendingTasksForBranchLike(pendingTasks, mainBranch)).toMatchObject([{}]); - expect(component.getPendingTasksForBranchLike(pendingTasks, pullRequest)).toMatchObject([ - currentTask, - ]); + wrapper.setProps({ location: mockLocation({ query: { pullRequest: pullRequest.key } }) }); + expect(component.getCurrentTask(currentTask)).toMatchObject(currentTask); + + expect(component.getPendingTasksForBranchLike(pendingTasks)).toMatchObject([currentTask]); + wrapper.setProps({ location: mockLocation({ query: {} }) }); + expect(component.getPendingTasksForBranchLike(pendingTasks)).toMatchObject([{}]); }); it('reload component after task progress finished', async () => { @@ -393,22 +354,6 @@ it('should display display the unavailable page if the component needs issue syn expect(wrapper.find(PageUnavailableDueToIndexation).exists()).toBe(true); }); -it('should correctly reload last task warnings if anything got dismissed', async () => { - (getComponentData as jest.Mock<any>).mockResolvedValueOnce({ - component: mockComponent({ - breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: ComponentQualifier.Project }], - }), - }); - (getComponentNavigation as jest.Mock).mockResolvedValueOnce({}); - - const wrapper = shallowRender(); - await waitAndUpdate(wrapper); - (getAnalysisStatus as jest.Mock).mockClear(); - - wrapper.instance().handleWarningDismiss(); - expect(getAnalysisStatus).toHaveBeenCalledTimes(1); -}); - describe('should correctly validate the project binding depending on the context', () => { const COMPONENT = mockComponent({ breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: ComponentQualifier.Project }], @@ -461,7 +406,6 @@ function shallowRender(props: Partial<ComponentContainer['props']> = {}) { <ComponentContainer hasFeature={jest.fn().mockReturnValue(false)} location={mockLocation({ query: { id: 'foo' } })} - updateBranchStatus={jest.fn()} router={mockRouter()} {...props} > diff --git a/server/sonar-web/src/main/js/app/components/branch-status/BranchStatusContextProvider.tsx b/server/sonar-web/src/main/js/app/components/branch-status/BranchStatusContextProvider.tsx deleted file mode 100644 index cf2d2ba3279..00000000000 --- a/server/sonar-web/src/main/js/app/components/branch-status/BranchStatusContextProvider.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/* - * 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 * as React from 'react'; -import { getQualityGateProjectStatus } from '../../../api/quality-gates'; -import { getBranchLikeKey, getBranchLikeQuery } from '../../../helpers/branch-like'; -import { extractStatusConditionsFromProjectStatus } from '../../../helpers/qualityGates'; -import { BranchLike, BranchStatusData } from '../../../types/branch-like'; -import { QualityGateStatusCondition } from '../../../types/quality-gates'; -import { Dict, Status } from '../../../types/types'; -import { BranchStatusContext } from './BranchStatusContext'; - -interface State { - branchStatusByComponent: Dict<Dict<BranchStatusData>>; -} - -export default class BranchStatusContextProvider extends React.PureComponent<{}, State> { - mounted = false; - state: State = { - branchStatusByComponent: {}, - }; - - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - fetchBranchStatus = async (branchLike: BranchLike, projectKey: string) => { - const projectStatus = await getQualityGateProjectStatus({ - projectKey, - ...getBranchLikeQuery(branchLike), - }).catch(() => undefined); - - if (!this.mounted || projectStatus === undefined) { - return; - } - - const { ignoredConditions, status } = projectStatus; - const conditions = extractStatusConditionsFromProjectStatus(projectStatus); - - this.updateBranchStatus(branchLike, projectKey, status, conditions, ignoredConditions); - }; - - updateBranchStatus = ( - branchLike: BranchLike, - projectKey: string, - status: Status, - conditions?: QualityGateStatusCondition[], - ignoredConditions?: boolean - ) => { - const branchLikeKey = getBranchLikeKey(branchLike); - - this.setState(({ branchStatusByComponent }) => ({ - branchStatusByComponent: { - ...branchStatusByComponent, - [projectKey]: { - ...(branchStatusByComponent[projectKey] || {}), - [branchLikeKey]: { - conditions, - ignoredConditions, - status, - }, - }, - }, - })); - }; - - render() { - return ( - <BranchStatusContext.Provider - value={{ - branchStatusByComponent: this.state.branchStatusByComponent, - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => { - this.fetchBranchStatus(branchLike, projectKey).catch(() => { - /* noop */ - }); - }, - updateBranchStatus: this.updateBranchStatus, - }} - > - {this.props.children} - </BranchStatusContext.Provider> - ); - } -} diff --git a/server/sonar-web/src/main/js/app/components/branch-status/__tests__/BranchStatusContextProvider-test.tsx b/server/sonar-web/src/main/js/app/components/branch-status/__tests__/BranchStatusContextProvider-test.tsx deleted file mode 100644 index 4add5f75713..00000000000 --- a/server/sonar-web/src/main/js/app/components/branch-status/__tests__/BranchStatusContextProvider-test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * 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 { shallow } from 'enzyme'; -import * as React from 'react'; -import { getQualityGateProjectStatus } from '../../../../api/quality-gates'; -import { mockBranch } from '../../../../helpers/mocks/branch-like'; -import { waitAndUpdate } from '../../../../helpers/testUtils'; -import { BranchStatusData } from '../../../../types/branch-like'; -import BranchStatusContextProvider from '../BranchStatusContextProvider'; - -jest.mock('../../../../api/quality-gates', () => ({ - getQualityGateProjectStatus: jest.fn().mockResolvedValue({}), -})); - -describe('fetchBranchStatus', () => { - it('should get the branch status', async () => { - const projectKey = 'projectKey'; - const branchName = 'branch-6.7'; - const status: BranchStatusData = { - status: 'OK', - conditions: [], - ignoredConditions: false, - }; - (getQualityGateProjectStatus as jest.Mock).mockResolvedValueOnce(status); - const wrapper = shallowRender(); - - wrapper.instance().fetchBranchStatus(mockBranch({ name: branchName }), projectKey); - - expect(getQualityGateProjectStatus).toHaveBeenCalledWith({ projectKey, branch: branchName }); - - await waitAndUpdate(wrapper); - - expect(wrapper.state().branchStatusByComponent).toEqual({ - [projectKey]: { [`branch-${branchName}`]: status }, - }); - }); - - it('should ignore errors', async () => { - (getQualityGateProjectStatus as jest.Mock).mockRejectedValueOnce('error'); - const wrapper = shallowRender(); - - wrapper.instance().fetchBranchStatus(mockBranch(), 'project'); - - await waitAndUpdate(wrapper); - - expect(wrapper.state().branchStatusByComponent).toEqual({}); - }); -}); - -function shallowRender() { - return shallow<BranchStatusContextProvider>( - <BranchStatusContextProvider> - <div /> - </BranchStatusContextProvider> - ); -} diff --git a/server/sonar-web/src/main/js/app/components/branch-status/withBranchStatus.tsx b/server/sonar-web/src/main/js/app/components/branch-status/withBranchStatus.tsx deleted file mode 100644 index 94339400002..00000000000 --- a/server/sonar-web/src/main/js/app/components/branch-status/withBranchStatus.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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 * as React from 'react'; -import { getWrappedDisplayName } from '../../../components/hoc/utils'; -import { getBranchStatusByBranchLike } from '../../../helpers/branch-like'; -import { BranchLike, BranchStatusData } from '../../../types/branch-like'; -import { Component } from '../../../types/types'; -import { BranchStatusContext } from './BranchStatusContext'; - -export default function withBranchStatus< - P extends { branchLike: BranchLike; component: Component } ->(WrappedComponent: React.ComponentType<P & BranchStatusData>) { - return class WithBranchStatus extends React.PureComponent<Omit<P, keyof BranchStatusData>> { - static displayName = getWrappedDisplayName(WrappedComponent, 'withBranchStatus'); - - render() { - const { branchLike, component } = this.props; - - return ( - <BranchStatusContext.Consumer> - {({ branchStatusByComponent }) => { - const { conditions, ignoredConditions, status } = getBranchStatusByBranchLike( - branchStatusByComponent, - component.key, - branchLike - ); - - return ( - <WrappedComponent - conditions={conditions} - ignoredConditions={ignoredConditions} - status={status} - {...(this.props as P)} - /> - ); - }} - </BranchStatusContext.Consumer> - ); - } - }; -} diff --git a/server/sonar-web/src/main/js/app/components/branch-status/withBranchStatusActions.tsx b/server/sonar-web/src/main/js/app/components/branch-status/withBranchStatusActions.tsx deleted file mode 100644 index 365c0301b51..00000000000 --- a/server/sonar-web/src/main/js/app/components/branch-status/withBranchStatusActions.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 * as React from 'react'; -import { getWrappedDisplayName } from '../../../components/hoc/utils'; -import { BranchStatusContext, BranchStatusContextInterface } from './BranchStatusContext'; - -export type WithBranchStatusActionsProps = - | Pick<BranchStatusContextInterface, 'fetchBranchStatus'> - | Pick<BranchStatusContextInterface, 'updateBranchStatus'>; - -export default function withBranchStatusActions<P>( - WrappedComponent: React.ComponentType<P & WithBranchStatusActionsProps> -) { - return class WithBranchStatusActions extends React.PureComponent< - Omit<P, keyof BranchStatusContextInterface> - > { - static displayName = getWrappedDisplayName(WrappedComponent, 'withBranchStatusActions'); - - render() { - return ( - <BranchStatusContext.Consumer> - {({ fetchBranchStatus, updateBranchStatus }) => ( - <WrappedComponent - fetchBranchStatus={fetchBranchStatus} - updateBranchStatus={updateBranchStatus} - {...(this.props as P)} - /> - )} - </BranchStatusContext.Consumer> - ); - } - }; -} diff --git a/server/sonar-web/src/main/js/app/components/componentContext/ComponentContext.ts b/server/sonar-web/src/main/js/app/components/componentContext/ComponentContext.ts index 4f19ef2eda3..8fd8ae528ea 100644 --- a/server/sonar-web/src/main/js/app/components/componentContext/ComponentContext.ts +++ b/server/sonar-web/src/main/js/app/components/componentContext/ComponentContext.ts @@ -22,7 +22,5 @@ import * as React from 'react'; import { ComponentContextShape } from '../../../types/component'; export const ComponentContext = React.createContext<ComponentContextShape>({ - branchLikes: [], - onBranchesChange: noop, onComponentChange: noop, }); diff --git a/server/sonar-web/src/main/js/app/components/current-user/withCurrentUserContext.tsx b/server/sonar-web/src/main/js/app/components/current-user/withCurrentUserContext.tsx index 7849c5e9554..9f0e85ba96d 100644 --- a/server/sonar-web/src/main/js/app/components/current-user/withCurrentUserContext.tsx +++ b/server/sonar-web/src/main/js/app/components/current-user/withCurrentUserContext.tsx @@ -25,7 +25,7 @@ export default function withCurrentUserContext<P>( WrappedComponent: React.ComponentType<P & Pick<CurrentUserContextInterface, 'currentUser'>> ) { return class WithCurrentUserContext extends React.PureComponent< - Omit<P, keyof CurrentUserContextInterface> + Omit<P, 'currentUser' | 'updateCurrentUserHomepage' | 'updateDismissedNotices'> > { static displayName = getWrappedDisplayName(WrappedComponent, 'withCurrentUserContext'); diff --git a/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx b/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx index d3535c539be..179d690caa9 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx +++ b/server/sonar-web/src/main/js/app/components/extensions/Extension.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { withTheme } from '@emotion/react'; +import { QueryClient } from '@tanstack/react-query'; import { Theme } from 'design-system'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; @@ -28,6 +29,7 @@ import { addGlobalErrorMessage } from '../../../helpers/globalMessages'; import { translate } from '../../../helpers/l10n'; import { getCurrentL10nBundle } from '../../../helpers/l10nBundle'; import { getBaseUrl } from '../../../helpers/system'; +import { withQueryClient } from '../../../queries/withQueryClientHoc'; import { AppState } from '../../../types/appstate'; import { ExtensionStartMethod } from '../../../types/extension'; import { Dict, Extension as TypeExtension } from '../../../types/types'; @@ -44,6 +46,7 @@ export interface ExtensionProps extends WrappedComponentProps { location: Location; options?: Dict<any>; router: Router; + queryClient: QueryClient; updateCurrentUserHomepage: (homepage: HomePage) => void; } @@ -74,7 +77,7 @@ class Extension extends React.PureComponent<ExtensionProps, State> { } handleStart = (start: ExtensionStartMethod) => { - const { theme: dsTheme } = this.props; + const { theme: dsTheme, queryClient } = this.props; const result = start({ appState: this.props.appState, el: this.container, @@ -90,6 +93,7 @@ class Extension extends React.PureComponent<ExtensionProps, State> { // See SONAR-16207 and core-extension-enterprise-server/src/main/js/portfolios/components/Header.tsx // for more information on why we're passing this as a prop to an extension. updateCurrentUserHomepage: this.props.updateCurrentUserHomepage, + queryClient, ...this.props.options, }); @@ -134,5 +138,5 @@ class Extension extends React.PureComponent<ExtensionProps, State> { } export default injectIntl( - withRouter(withTheme(withAppStateContext(withCurrentUserContext(Extension)))) + withRouter(withTheme(withAppStateContext(withCurrentUserContext(withQueryClient(Extension))))) ); diff --git a/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx b/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx index 83046f78c3a..26ed70a56e9 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx +++ b/server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx @@ -19,13 +19,17 @@ */ import * as React from 'react'; import { useParams } from 'react-router-dom'; +import { useRefreshBranches } from '../../../queries/branch'; import NotFound from '../NotFound'; import { ComponentContext } from '../componentContext/ComponentContext'; import Extension from './Extension'; export default function ProjectAdminPageExtension() { const { extensionKey, pluginKey } = useParams(); - const { component, onBranchesChange, onComponentChange } = React.useContext(ComponentContext); + const { component, onComponentChange } = React.useContext(ComponentContext); + + // We keep that for compatibility but ideally should advocate to use tanstack query + const onBranchesChange = useRefreshBranches(); const extension = component && @@ -35,7 +39,7 @@ export default function ProjectAdminPageExtension() { ); return extension ? ( - <Extension extension={extension} options={{ component, onBranchesChange, onComponentChange }} /> + <Extension extension={extension} options={{ component, onComponentChange, onBranchesChange }} /> ) : ( <NotFound withContainer={false} /> ); diff --git a/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx b/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx index 747504f02a8..33b78f11844 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx +++ b/server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx @@ -19,6 +19,7 @@ */ import * as React from 'react'; import { useParams } from 'react-router-dom'; +import { useBranchesQuery } from '../../../queries/branch'; import NotFound from '../NotFound'; import { ComponentContext } from '../componentContext/ComponentContext'; import Extension from './Extension'; @@ -32,12 +33,14 @@ export interface ProjectPageExtensionProps { export default function ProjectPageExtension({ params }: ProjectPageExtensionProps) { const { extensionKey, pluginKey } = useParams(); - const { branchLike, component } = React.useContext(ComponentContext); + const { component } = React.useContext(ComponentContext); + const { data } = useBranchesQuery(component); - if (component === undefined) { + if (component === undefined || data === undefined) { return null; } + const { branchLike } = data; const fullKey = params !== undefined ? `${params.pluginKey}/${params.extensionKey}` diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.tsx b/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.tsx index e0d6a4c327d..5d7ed345ae8 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.tsx +++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import * as React from 'react'; import { HelmetProvider } from 'react-helmet-async'; @@ -59,17 +60,20 @@ function renderProjectAdminPageExtension( } ) { const { pluginKey, extensionKey } = params; + const queryClient = new QueryClient(); return render( - <HelmetProvider context={{}}> - <IntlProvider defaultLocale="en" locale="en"> - <ComponentContext.Provider value={{ component } as ComponentContextShape}> - <MemoryRouter initialEntries={[`/${pluginKey}/${extensionKey}`]}> - <Routes> - <Route path="/:pluginKey/:extensionKey" element={<ProjectAdminPageExtension />} /> - </Routes> - </MemoryRouter> - </ComponentContext.Provider> - </IntlProvider> - </HelmetProvider> + <QueryClientProvider client={queryClient}> + <HelmetProvider context={{}}> + <IntlProvider defaultLocale="en" locale="en"> + <ComponentContext.Provider value={{ component } as ComponentContextShape}> + <MemoryRouter initialEntries={[`/${pluginKey}/${extensionKey}`]}> + <Routes> + <Route path="/:pluginKey/:extensionKey" element={<ProjectAdminPageExtension />} /> + </Routes> + </MemoryRouter> + </ComponentContext.Provider> + </IntlProvider> + </HelmetProvider> + </QueryClientProvider> ); } diff --git a/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectPageExtension-test.tsx b/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectPageExtension-test.tsx index c2f8e871846..d61aeee9739 100644 --- a/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectPageExtension-test.tsx +++ b/server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectPageExtension-test.tsx @@ -17,11 +17,13 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; import { HelmetProvider } from 'react-helmet-async'; import { IntlProvider } from 'react-intl'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import BranchesServiceMock from '../../../../api/mocks/BranchesServiceMock'; import { getExtensionStart } from '../../../../helpers/extensions'; import { mockComponent } from '../../../../helpers/mocks/component'; import { ComponentContextShape } from '../../../../types/component'; @@ -33,51 +35,60 @@ jest.mock('../../../../helpers/extensions', () => ({ getExtensionStart: jest.fn().mockResolvedValue(jest.fn()), })); +const handler = new BranchesServiceMock(); + +beforeEach(() => { + handler.reset(); +}); + it('should not render when no component is passed', () => { renderProjectPageExtension(); expect(screen.queryByText('page_not_found')).not.toBeInTheDocument(); expect(getExtensionStart).not.toHaveBeenCalledWith('pluginId/extensionId'); }); -it('should render correctly when the extension is found', () => { +it('should render correctly when the extension is found', async () => { renderProjectPageExtension( mockComponent({ extensions: [{ key: 'pluginId/extensionId', name: 'name' }] }), { params: { pluginKey: 'pluginId', extensionKey: 'extensionId' } } ); - expect(getExtensionStart).toHaveBeenCalledWith('pluginId/extensionId'); + await waitFor(() => expect(getExtensionStart).toHaveBeenCalledWith('pluginId/extensionId')); }); -it('should render correctly when the extension is not found', () => { +it('should render correctly when the extension is not found', async () => { renderProjectPageExtension( mockComponent({ extensions: [{ key: 'pluginId/extensionId', name: 'name' }] }), { params: { pluginKey: 'not-found-plugin', extensionKey: 'not-found-extension' } } ); - expect(screen.getByText('page_not_found')).toBeInTheDocument(); + expect(await screen.findByText('page_not_found')).toBeInTheDocument(); }); function renderProjectPageExtension( component?: Component, props?: Partial<ProjectPageExtensionProps> ) { + const queryClient = new QueryClient(); return render( - <HelmetProvider context={{}}> - <IntlProvider defaultLocale="en" locale="en"> - <ComponentContext.Provider value={{ component } as ComponentContextShape}> - <MemoryRouter> - <Routes> - <Route - path="*" - element={ - <ProjectPageExtension - params={{ extensionKey: 'extensionId', pluginKey: 'pluginId' }} - {...props} - /> - } - /> - </Routes> - </MemoryRouter> - </ComponentContext.Provider> - </IntlProvider> - </HelmetProvider> + <QueryClientProvider client={queryClient}> + <HelmetProvider context={{}}> + <IntlProvider defaultLocale="en" locale="en"> + <ComponentContext.Provider value={{ component } as ComponentContextShape}> + <MemoryRouter initialEntries={[`/?id=${component?.key}`]}> + <Routes> + <Route + path="*" + element={ + <ProjectPageExtension + params={{ extensionKey: 'extensionId', pluginKey: 'pluginId' }} + {...props} + /> + } + /> + </Routes> + </MemoryRouter> + </ComponentContext.Provider> + </IntlProvider> + </HelmetProvider> + </QueryClientProvider> ); } diff --git a/server/sonar-web/src/main/js/app/components/global-search/utils.ts b/server/sonar-web/src/main/js/app/components/global-search/utils.ts index 4fbc1c4b1db..15ca34de7c3 100644 --- a/server/sonar-web/src/main/js/app/components/global-search/utils.ts +++ b/server/sonar-web/src/main/js/app/components/global-search/utils.ts @@ -21,7 +21,6 @@ import { sortBy } from 'lodash'; import { ComponentQualifier } from '../../../../js/types/component'; const ORDER = [ - ComponentQualifier.Developper, ComponentQualifier.Portfolio, ComponentQualifier.SubPortfolio, ComponentQualifier.Application, diff --git a/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorMessage.tsx b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorMessage.tsx index 00b8bda2c62..0ec4af077df 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorMessage.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorMessage.tsx @@ -22,20 +22,39 @@ import { Link } from 'design-system'; import React from 'react'; import { FormattedMessage } from 'react-intl'; import { useLocation } from 'react-router-dom'; +import { isBranch, isMainBranch, isPullRequest } from '../../../../helpers/branch-like'; import { hasMessage, translate } from '../../../../helpers/l10n'; import { getComponentBackgroundTaskUrl } from '../../../../helpers/urls'; +import { useBranchesQuery } from '../../../../queries/branch'; +import { BranchLike } from '../../../../types/branch-like'; import { Task } from '../../../../types/tasks'; import { Component } from '../../../../types/types'; interface Props { component: Component; currentTask: Task; - currentTaskOnSameBranch?: boolean; onLeave: () => void; } +function isSameBranch(task: Task, branchLike?: BranchLike) { + if (branchLike) { + if (isMainBranch(branchLike)) { + return (!task.pullRequest && !task.branch) || branchLike.name === task.branch; + } + if (isPullRequest(branchLike)) { + return branchLike.key === task.pullRequest; + } + if (isBranch(branchLike)) { + return branchLike.name === task.branch; + } + } + return !task.branch && !task.pullRequest; +} + export function AnalysisErrorMessage(props: Props) { - const { component, currentTask, currentTaskOnSameBranch } = props; + const { component, currentTask } = props; + const { data: { branchLike } = {} } = useBranchesQuery(component); + const currentTaskOnSameBranch = isSameBranch(currentTask, branchLike); const location = useLocation(); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorModal.tsx b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorModal.tsx index bd368784421..ea77cdbc343 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorModal.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorModal.tsx @@ -29,12 +29,11 @@ import { AnalysisLicenseError } from './AnalysisLicenseError'; interface Props { component: Component; currentTask: Task; - currentTaskOnSameBranch?: boolean; onClose: () => void; } export function AnalysisErrorModal(props: Props) { - const { component, currentTask, currentTaskOnSameBranch } = props; + const { component, currentTask } = props; const header = translate('error'); @@ -55,7 +54,6 @@ export function AnalysisErrorModal(props: Props) { <AnalysisErrorMessage component={component} currentTask={currentTask} - currentTaskOnSameBranch={currentTaskOnSameBranch} onLeave={props.onClose} /> )} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/AnalysisStatus.tsx b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisStatus.tsx index 4d8953398fa..a082485aa65 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/AnalysisStatus.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisStatus.tsx @@ -19,25 +19,23 @@ */ import { DeferredSpinner, FlagMessage, Link } from 'design-system'; import * as React from 'react'; -import AnalysisWarningsModal from '../../../../components/common/AnalysisWarningsModal'; import { translate } from '../../../../helpers/l10n'; -import { Task, TaskStatuses, TaskWarning } from '../../../../types/tasks'; +import { useBranchWarrningQuery } from '../../../../queries/branch'; +import { Task, TaskStatuses } from '../../../../types/tasks'; import { Component } from '../../../../types/types'; import { AnalysisErrorModal } from './AnalysisErrorModal'; +import AnalysisWarningsModal from './AnalysisWarningsModal'; export interface HeaderMetaProps { currentTask?: Task; - currentTaskOnSameBranch?: boolean; component: Component; isInProgress?: boolean; isPending?: boolean; - onWarningDismiss: () => void; - warnings: TaskWarning[]; } export function AnalysisStatus(props: HeaderMetaProps) { - const { component, currentTask, currentTaskOnSameBranch, isInProgress, isPending, warnings } = - props; + const { component, currentTask, isInProgress, isPending } = props; + const { data: warnings, isLoading } = useBranchWarrningQuery(component); const [modalIsVisible, setDisplayModal] = React.useState(false); const openModal = React.useCallback(() => { @@ -73,7 +71,6 @@ export function AnalysisStatus(props: HeaderMetaProps) { <AnalysisErrorModal component={component} currentTask={currentTask} - currentTaskOnSameBranch={currentTaskOnSameBranch} onClose={closeModal} /> )} @@ -81,7 +78,7 @@ export function AnalysisStatus(props: HeaderMetaProps) { ); } - if (warnings.length > 0) { + if (!isLoading && warnings && warnings.length > 0) { return ( <> <FlagMessage variant="warning"> @@ -91,13 +88,7 @@ export function AnalysisStatus(props: HeaderMetaProps) { </Link> </FlagMessage> {modalIsVisible && ( - <AnalysisWarningsModal - componentKey={component.key} - onClose={closeModal} - taskId={currentTask?.id} - onWarningDismiss={props.onWarningDismiss} - warnings={warnings} - /> + <AnalysisWarningsModal component={component} onClose={closeModal} warnings={warnings} /> )} </> ); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/AnalysisWarningsModal.tsx b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisWarningsModal.tsx new file mode 100644 index 00000000000..90f7da97609 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/component/AnalysisWarningsModal.tsx @@ -0,0 +1,103 @@ +/* + * 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 { + DangerButtonSecondary, + DeferredSpinner, + FlagMessage, + HtmlFormatter, + Modal, +} from 'design-system'; +import * as React from 'react'; +import { translate } from '../../../../helpers/l10n'; +import { sanitizeStringRestricted } from '../../../../helpers/sanitize'; +import { useDismissBranchWarningMutation } from '../../../../queries/branch'; +import { TaskWarning } from '../../../../types/tasks'; +import { Component } from '../../../../types/types'; +import { CurrentUser } from '../../../../types/users'; +import withCurrentUserContext from '../../current-user/withCurrentUserContext'; + +interface Props { + component: Component; + currentUser: CurrentUser; + onClose: () => void; + warnings: TaskWarning[]; +} + +export function AnalysisWarningsModal(props: Props) { + const { component, currentUser, warnings } = props; + + const { mutate, isLoading, variables } = useDismissBranchWarningMutation(); + + const handleDismissMessage = (messageKey: string) => { + mutate({ component, key: messageKey }); + }; + + const body = ( + <> + {warnings.map(({ dismissable, key, message }) => ( + <React.Fragment key={key}> + <div className="sw-flex sw-items-center sw-mt-2"> + <FlagMessage variant="warning"> + <HtmlFormatter> + <span + // eslint-disable-next-line react/no-danger + dangerouslySetInnerHTML={{ + __html: sanitizeStringRestricted(message.trim().replace(/\n/g, '<br />')), + }} + /> + </HtmlFormatter> + </FlagMessage> + </div> + <div> + {dismissable && currentUser.isLoggedIn && ( + <div className="sw-mt-4"> + <DangerButtonSecondary + disabled={Boolean(isLoading)} + onClick={() => { + handleDismissMessage(key); + }} + > + {translate('dismiss_permanently')} + </DangerButtonSecondary> + + <DeferredSpinner + className="sw-ml-2" + loading={isLoading && variables?.key === key} + /> + </div> + )} + </div> + </React.Fragment> + ))} + </> + ); + + return ( + <Modal + headerTitle={translate('warnings')} + onClose={props.onClose} + body={body} + primaryButton={null} + secondaryButtonLabel={translate('close')} + /> + ); +} + +export default withCurrentUserContext(AnalysisWarningsModal); 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 3f1db236c3b..f81c5a66e04 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 @@ -24,9 +24,8 @@ import { ProjectAlmBindingConfigurationErrors, ProjectAlmBindingResponse, } from '../../../../types/alm-settings'; -import { BranchLike } from '../../../../types/branch-like'; import { ComponentQualifier } from '../../../../types/component'; -import { Task, TaskWarning } from '../../../../types/tasks'; +import { Task } from '../../../../types/tasks'; import { Component } from '../../../../types/types'; import RecentHistory from '../../RecentHistory'; import ComponentNavProjectBindingErrorNotif from './ComponentNavProjectBindingErrorNotif'; @@ -35,34 +34,17 @@ import HeaderMeta from './HeaderMeta'; import Menu from './Menu'; export interface ComponentNavProps { - branchLikes: BranchLike[]; - currentBranchLike: BranchLike | undefined; component: Component; currentTask?: Task; - currentTaskOnSameBranch?: boolean; isInProgress?: boolean; isPending?: boolean; - onWarningDismiss: () => void; projectBinding?: ProjectAlmBindingResponse; projectBindingErrors?: ProjectAlmBindingConfigurationErrors; - warnings: TaskWarning[]; } export default function ComponentNav(props: ComponentNavProps) { - const { - branchLikes, - component, - currentBranchLike, - currentTask, - currentTaskOnSameBranch, - isInProgress, - isPending, - projectBinding, - projectBindingErrors, - warnings, - } = props; - - const [displayProjectInfo, setDisplayProjectInfo] = React.useState(false); + const { component, currentTask, isInProgress, isPending, projectBinding, projectBindingErrors } = + props; React.useEffect(() => { const { breadcrumbs, key, name } = component; @@ -72,7 +54,6 @@ export default function ComponentNav(props: ComponentNavProps) { ComponentQualifier.Project, ComponentQualifier.Portfolio, ComponentQualifier.Application, - ComponentQualifier.Developper, ].includes(qualifier as ComponentQualifier) ) { RecentHistory.add(key, name, qualifier.toLowerCase()); @@ -88,34 +69,15 @@ export default function ComponentNav(props: ComponentNavProps) { <> <TopBar id="context-navigation" aria-label={translate('qualifier', component.qualifier)}> <div className="sw-min-h-10 sw-flex sw-justify-between"> - <Header - branchLikes={branchLikes} - component={component} - currentBranchLike={currentBranchLike} - projectBinding={projectBinding} - /> + <Header component={component} projectBinding={projectBinding} /> <HeaderMeta - branchLike={currentBranchLike} component={component} currentTask={currentTask} - currentTaskOnSameBranch={currentTaskOnSameBranch} isInProgress={isInProgress} isPending={isPending} - onWarningDismiss={props.onWarningDismiss} - warnings={warnings} /> </div> - <Menu - branchLike={currentBranchLike} - branchLikes={branchLikes} - component={component} - isInProgress={isInProgress} - isPending={isPending} - onToggleProjectInfo={() => { - setDisplayProjectInfo(!displayProjectInfo); - }} - projectInfoDisplayed={displayProjectInfo} - /> + <Menu component={component} isInProgress={isInProgress} isPending={isPending} /> </TopBar> {prDecoNotifComponent} </> diff --git a/server/sonar-web/src/main/js/app/components/nav/component/Header.tsx b/server/sonar-web/src/main/js/app/components/nav/component/Header.tsx index d52c2dc6728..92e82ebb4dc 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/Header.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/Header.tsx @@ -19,38 +19,26 @@ */ import * as React from 'react'; import { ProjectAlmBindingResponse } from '../../../../types/alm-settings'; -import { BranchLike } from '../../../../types/branch-like'; import { Component } from '../../../../types/types'; import { CurrentUser } from '../../../../types/users'; import withCurrentUserContext from '../../current-user/withCurrentUserContext'; -import BranchLikeNavigation from './branch-like/BranchLikeNavigation'; import { Breadcrumb } from './Breadcrumb'; +import BranchLikeNavigation from './branch-like/BranchLikeNavigation'; export interface HeaderProps { - branchLikes: BranchLike[]; component: Component; - currentBranchLike: BranchLike | undefined; currentUser: CurrentUser; projectBinding?: ProjectAlmBindingResponse; } export function Header(props: HeaderProps) { - const { branchLikes, component, currentBranchLike, currentUser, projectBinding } = props; + const { component, currentUser, projectBinding } = props; return ( <div className="sw-flex sw-flex-shrink sw-items-center"> <Breadcrumb component={component} currentUser={currentUser} /> - {currentBranchLike && ( - <> - <span className="slash-separator sw-mx-2" /> - <BranchLikeNavigation - branchLikes={branchLikes} - component={component} - currentBranchLike={currentBranchLike} - projectBinding={projectBinding} - /> - </> - )} + + <BranchLikeNavigation component={component} projectBinding={projectBinding} /> </div> ); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/HeaderMeta.tsx b/server/sonar-web/src/main/js/app/components/nav/component/HeaderMeta.tsx index 56d22719501..5e1043531c2 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/HeaderMeta.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/HeaderMeta.tsx @@ -22,8 +22,8 @@ import * as React from 'react'; import HomePageSelect from '../../../../components/controls/HomePageSelect'; import { isBranch, isPullRequest } from '../../../../helpers/branch-like'; import { translateWithParameters } from '../../../../helpers/l10n'; -import { BranchLike } from '../../../../types/branch-like'; -import { Task, TaskWarning } from '../../../../types/tasks'; +import { useBranchesQuery } from '../../../../queries/branch'; +import { Task } from '../../../../types/tasks'; import { Component } from '../../../../types/types'; import { CurrentUser, isLoggedIn } from '../../../../types/users'; import withCurrentUserContext from '../../current-user/withCurrentUserContext'; @@ -32,28 +32,17 @@ import CurrentBranchLikeMergeInformation from './branch-like/CurrentBranchLikeMe import { getCurrentPage } from './utils'; export interface HeaderMetaProps { - branchLike?: BranchLike; component: Component; currentUser: CurrentUser; currentTask?: Task; - currentTaskOnSameBranch?: boolean; isInProgress?: boolean; isPending?: boolean; - onWarningDismiss: () => void; - warnings: TaskWarning[]; } export function HeaderMeta(props: HeaderMetaProps) { - const { - branchLike, - component, - currentUser, - currentTask, - currentTaskOnSameBranch, - isInProgress, - isPending, - warnings, - } = props; + const { component, currentUser, currentTask, isInProgress, isPending } = props; + + const { data: { branchLike } = {} } = useBranchesQuery(component); const isABranch = isBranch(branchLike); @@ -64,11 +53,8 @@ export function HeaderMeta(props: HeaderMetaProps) { <AnalysisStatus component={component} currentTask={currentTask} - currentTaskOnSameBranch={currentTaskOnSameBranch} isInProgress={isInProgress} isPending={isPending} - onWarningDismiss={props.onWarningDismiss} - warnings={warnings} /> {branchLike && <CurrentBranchLikeMergeInformation currentBranchLike={branchLike} />} {component.version !== undefined && isABranch && ( diff --git a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx index a3a7111bc8f..d011f9d949e 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx @@ -30,8 +30,14 @@ import Tooltip from '../../../../components/controls/Tooltip'; import { getBranchLikeQuery, isPullRequest } from '../../../../helpers/branch-like'; import { hasMessage, translate, translateWithParameters } from '../../../../helpers/l10n'; import { getPortfolioUrl, getProjectQueryUrl } from '../../../../helpers/urls'; -import { BranchLike, BranchParameters } from '../../../../types/branch-like'; -import { ComponentQualifier, isPortfolioLike } from '../../../../types/component'; +import { useBranchesQuery } from '../../../../queries/branch'; +import { BranchParameters } from '../../../../types/branch-like'; +import { + ComponentQualifier, + isApplication, + isPortfolioLike, + isProject, +} from '../../../../types/component'; import { Feature } from '../../../../types/features'; import { Component, Dict, Extension } from '../../../../types/types'; import withAvailableFeatures, { @@ -55,84 +61,39 @@ const SETTINGS_URLS = [ ]; interface Props extends WithAvailableFeaturesProps { - branchLike: BranchLike | undefined; - branchLikes: BranchLike[] | undefined; component: Component; isInProgress?: boolean; isPending?: boolean; - onToggleProjectInfo: () => void; - projectInfoDisplayed: boolean; } type Query = BranchParameters & { id: string }; -export class Menu extends React.PureComponent<Props> { - projectInfoLink: HTMLElement | null = null; - - componentDidUpdate(prevProps: Props) { - if ( - prevProps.projectInfoDisplayed && - !this.props.projectInfoDisplayed && - this.projectInfoLink - ) { - this.projectInfoLink.focus(); - } - } +export function Menu(props: Props) { + const { component, isInProgress, isPending } = props; + const { extensions = [], canBrowseAllChildProjects, qualifier, configuration = {} } = component; + const { data: { branchLikes, branchLike } = { branchLikes: [] } } = useBranchesQuery(component); + const isApplicationChildInaccessble = isApplication(qualifier) && !canBrowseAllChildProjects; - hasAnalysis = () => { - const { branchLikes = [], component, isInProgress, isPending } = this.props; + const hasAnalysis = () => { const hasBranches = branchLikes.length > 1; return hasBranches || isInProgress || isPending || component.analysisDate !== undefined; }; - isProject = () => { - return this.props.component.qualifier === ComponentQualifier.Project; - }; - - isDeveloper = () => { - return this.props.component.qualifier === ComponentQualifier.Developper; - }; - - isPortfolio = () => { - const { qualifier } = this.props.component; - return isPortfolioLike(qualifier); - }; - - isApplication = () => { - return this.props.component.qualifier === ComponentQualifier.Application; - }; - - isAllChildProjectAccessible = () => { - return Boolean(this.props.component.canBrowseAllChildProjects); - }; + const isGovernanceEnabled = extensions.some((extension) => + extension.key.startsWith('governance/') + ); - isApplicationChildInaccessble = () => { - return this.isApplication() && !this.isAllChildProjectAccessible(); + const getQuery = (): Query => { + return { id: component.key, ...getBranchLikeQuery(branchLike) }; }; - isGovernanceEnabled = () => { - const { - component: { extensions }, - } = this.props; - - return extensions && extensions.some((extension) => extension.key.startsWith('governance/')); - }; - - getConfiguration = () => { - return this.props.component.configuration || {}; - }; - - getQuery = (): Query => { - return { id: this.props.component.key, ...getBranchLikeQuery(this.props.branchLike) }; - }; - - renderLinkWhenInaccessibleChild(label: React.ReactNode) { + const renderLinkWhenInaccessibleChild = (label: React.ReactNode) => { return ( <li> <Tooltip overlay={translateWithParameters( 'layout.all_project_must_be_accessible', - translate('qualifier', this.props.component.qualifier) + translate('qualifier', qualifier) )} > <a aria-disabled="true" className="disabled-link"> @@ -141,9 +102,9 @@ export class Menu extends React.PureComponent<Props> { </Tooltip> </li> ); - } + }; - renderMenuLink = ({ + const renderMenuLink = ({ label, pathname, additionalQueryParams = {}, @@ -152,13 +113,11 @@ export class Menu extends React.PureComponent<Props> { pathname: string; additionalQueryParams?: Dict<string>; }) => { - const hasAnalysis = this.hasAnalysis(); - const isApplicationChildInaccessble = this.isApplicationChildInaccessble(); - const query = this.getQuery(); + const query = getQuery(); if (isApplicationChildInaccessble) { - return this.renderLinkWhenInaccessibleChild(label); + return renderLinkWhenInaccessibleChild(label); } - return hasAnalysis ? ( + return hasAnalysis() ? ( <NavBarTabLink to={{ pathname, @@ -171,86 +130,82 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderDashboardLink = () => { - const { id, ...branchLike } = this.getQuery(); + const renderDashboardLink = () => { + const { id, ...branchLike } = getQuery(); - if (this.isPortfolio()) { - return this.isGovernanceEnabled() ? ( + if (isPortfolioLike(qualifier)) { + return isGovernanceEnabled ? ( <NavBarTabLink to={getPortfolioUrl(id)} text={translate('overview.page')} /> ) : null; } - const isApplicationChildInaccessble = this.isApplicationChildInaccessble(); if (isApplicationChildInaccessble) { - return this.renderLinkWhenInaccessibleChild(translate('overview.page')); + return renderLinkWhenInaccessibleChild(translate('overview.page')); } return ( <NavBarTabLink to={getProjectQueryUrl(id, branchLike)} text={translate('overview.page')} /> ); }; - renderBreakdownLink = () => { - return this.isPortfolio() && this.isGovernanceEnabled() - ? this.renderMenuLink({ + const renderBreakdownLink = () => { + return isPortfolioLike(qualifier) && isGovernanceEnabled + ? renderMenuLink({ label: translate('portfolio_breakdown.page'), pathname: '/code', }) : null; }; - renderCodeLink = () => { - if (this.isPortfolio() || this.isDeveloper()) { + const renderCodeLink = () => { + if (isPortfolioLike(qualifier)) { return null; } - const label = this.isApplication() ? translate('view_projects.page') : translate('code.page'); + const label = isApplication(qualifier) + ? translate('view_projects.page') + : translate('code.page'); - return this.renderMenuLink({ label, pathname: '/code' }); + return renderMenuLink({ label, pathname: '/code' }); }; - renderActivityLink = () => { - const { branchLike } = this.props; - + const renderActivityLink = () => { if (isPullRequest(branchLike)) { return null; } - return this.renderMenuLink({ + return renderMenuLink({ label: translate('project_activity.page'), pathname: '/project/activity', }); }; - renderIssuesLink = () => { - return this.renderMenuLink({ + const renderIssuesLink = () => { + return renderMenuLink({ label: translate('issues.page'), pathname: '/project/issues', additionalQueryParams: { resolved: 'false' }, }); }; - renderComponentMeasuresLink = () => { - return this.renderMenuLink({ + const renderComponentMeasuresLink = () => { + return renderMenuLink({ label: translate('layout.measures'), pathname: '/component_measures', }); }; - renderSecurityHotspotsLink = () => { - const isPortfolio = this.isPortfolio(); + const renderSecurityHotspotsLink = () => { + const isPortfolio = isPortfolioLike(qualifier); return ( !isPortfolio && - this.renderMenuLink({ + renderMenuLink({ label: translate('layout.security_hotspots'), pathname: '/security_hotspots', }) ); }; - renderSecurityReports = () => { - const { branchLike, component } = this.props; - const { extensions = [] } = component; - + const renderSecurityReports = () => { if (isPullRequest(branchLike)) { return null; } @@ -263,26 +218,27 @@ export class Menu extends React.PureComponent<Props> { return null; } - return this.renderMenuLink({ + return renderMenuLink({ label: translate('layout.security_reports'), pathname: '/project/extension/securityreport/securityreport', }); }; - renderAdministration = () => { - const { branchLike, component } = this.props; - const isProject = this.isProject(); - const isPortfolio = this.isPortfolio(); - const isApplication = this.isApplication(); - const query = this.getQuery(); + const renderAdministration = () => { + const query = getQuery(); - if (!this.getConfiguration().showSettings || isPullRequest(branchLike)) { + if (!configuration.showSettings || isPullRequest(branchLike)) { return null; } const isSettingsActive = SETTINGS_URLS.some((url) => window.location.href.includes(url)); - const adminLinks = this.renderAdministrationLinks(query, isProject, isApplication, isPortfolio); + const adminLinks = renderAdministrationLinks( + query, + isProject(qualifier), + isApplication(qualifier), + isPortfolioLike(qualifier) + ); if (!adminLinks.some((link) => link != null)) { return null; } @@ -313,46 +269,43 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderAdministrationLinks = ( + const renderAdministrationLinks = ( query: Query, isProject: boolean, isApplication: boolean, isPortfolio: boolean ) => { return [ - this.renderSettingsLink(query, isApplication, isPortfolio), - this.renderBranchesLink(query, isProject), - this.renderBaselineLink(query, isApplication, isPortfolio), - ...this.renderAdminExtensions(query, isApplication), - this.renderImportExportLink(query, isProject), - this.renderProfilesLink(query), - this.renderQualityGateLink(query), - this.renderLinksLink(query), - this.renderPermissionsLink(query), - this.renderBackgroundTasksLink(query), - this.renderUpdateKeyLink(query), - this.renderWebhooksLink(query, isProject), - this.renderDeletionLink(query), + renderSettingsLink(query, isApplication, isPortfolio), + renderBranchesLink(query, isProject), + renderBaselineLink(query, isApplication, isPortfolio), + ...renderAdminExtensions(query, isApplication), + renderImportExportLink(query, isProject), + renderProfilesLink(query), + renderQualityGateLink(query), + renderLinksLink(query), + renderPermissionsLink(query), + renderBackgroundTasksLink(query), + renderUpdateKeyLink(query), + renderWebhooksLink(query, isProject), + renderDeletionLink(query), ]; }; - renderProjectInformationButton = () => { - const isProject = this.isProject(); - const isApplication = this.isApplication(); - const label = translate(isProject ? 'project' : 'application', 'info.title'); - const isApplicationChildInaccessble = this.isApplicationChildInaccessble(); - const query = this.getQuery(); + const renderProjectInformationButton = () => { + const label = translate(isProject(qualifier) ? 'project' : 'application', 'info.title'); + const query = getQuery(); - if (isPullRequest(this.props.branchLike)) { + if (isPullRequest(branchLike)) { return null; } if (isApplicationChildInaccessble) { - return this.renderLinkWhenInaccessibleChild(label); + return renderLinkWhenInaccessibleChild(label); } return ( - (isProject || isApplication) && ( + (isProject(qualifier) || isApplication(qualifier)) && ( <NavBarTabLink to={{ pathname: '/project/information', search: new URLSearchParams(query).toString() }} text={label} @@ -361,8 +314,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderSettingsLink = (query: Query, isApplication: boolean, isPortfolio: boolean) => { - if (!this.getConfiguration().showSettings || isApplication || isPortfolio) { + const renderSettingsLink = (query: Query, isApplication: boolean, isPortfolio: boolean) => { + if (!configuration.showSettings || isApplication || isPortfolio) { return null; } return ( @@ -375,12 +328,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderBranchesLink = (query: Query, isProject: boolean) => { - if ( - !this.props.hasFeature(Feature.BranchSupport) || - !isProject || - !this.getConfiguration().showSettings - ) { + const renderBranchesLink = (query: Query, isProject: boolean) => { + if (!props.hasFeature(Feature.BranchSupport) || !isProject || !configuration.showSettings) { return null; } @@ -394,8 +343,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderBaselineLink = (query: Query, isApplication: boolean, isPortfolio: boolean) => { - if (!this.getConfiguration().showSettings || isApplication || isPortfolio) { + const renderBaselineLink = (query: Query, isApplication: boolean, isPortfolio: boolean) => { + if (!configuration.showSettings || isApplication || isPortfolio) { return null; } return ( @@ -408,7 +357,7 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderImportExportLink = (query: Query, isProject: boolean) => { + const renderImportExportLink = (query: Query, isProject: boolean) => { if (!isProject) { return null; } @@ -425,8 +374,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderProfilesLink = (query: Query) => { - if (!this.getConfiguration().showQualityProfiles) { + const renderProfilesLink = (query: Query) => { + if (!configuration.showQualityProfiles) { return null; } return ( @@ -442,8 +391,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderQualityGateLink = (query: Query) => { - if (!this.getConfiguration().showQualityGates) { + const renderQualityGateLink = (query: Query) => { + if (!configuration.showQualityGates) { return null; } return ( @@ -456,8 +405,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderLinksLink = (query: Query) => { - if (!this.getConfiguration().showLinks) { + const renderLinksLink = (query: Query) => { + if (!configuration.showLinks) { return null; } return ( @@ -470,8 +419,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderPermissionsLink = (query: Query) => { - if (!this.getConfiguration().showPermissions) { + const renderPermissionsLink = (query: Query) => { + if (!configuration.showPermissions) { return null; } return ( @@ -484,8 +433,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderBackgroundTasksLink = (query: Query) => { - if (!this.getConfiguration().showBackgroundTasks) { + const renderBackgroundTasksLink = (query: Query) => { + if (!configuration.showBackgroundTasks) { return null; } return ( @@ -501,8 +450,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderUpdateKeyLink = (query: Query) => { - if (!this.getConfiguration().showUpdateKey) { + const renderUpdateKeyLink = (query: Query) => { + if (!configuration.showUpdateKey) { return null; } return ( @@ -515,8 +464,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderWebhooksLink = (query: Query, isProject: boolean) => { - if (!this.getConfiguration().showSettings || !isProject) { + const renderWebhooksLink = (query: Query, isProject: boolean) => { + if (!configuration.showSettings || !isProject) { return null; } return ( @@ -529,10 +478,8 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderDeletionLink = (query: Query) => { - const { qualifier } = this.props.component; - - if (!this.getConfiguration().showSettings) { + const renderDeletionLink = (query: Query) => { + if (!configuration.showSettings) { return null; } @@ -556,9 +503,9 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderExtension = ({ key, name }: Extension, isAdmin: boolean, baseQuery: Query) => { + const renderExtension = ({ key, name }: Extension, isAdmin: boolean, baseQuery: Query) => { const pathname = isAdmin ? `/project/admin/extension/${key}` : `/project/extension/${key}`; - const query = { ...baseQuery, qualifier: this.props.component.qualifier }; + const query = { ...baseQuery, qualifier }; return ( <ItemNavLink key={key} to={{ pathname, search: new URLSearchParams(query).toString() }}> {name} @@ -566,16 +513,15 @@ export class Menu extends React.PureComponent<Props> { ); }; - renderAdminExtensions = (query: Query, isApplication: boolean) => { - const extensions = this.getConfiguration().extensions || []; + const renderAdminExtensions = (query: Query, isApplication: boolean) => { + const extensions = component.configuration?.extensions ?? []; return extensions .filter((e) => !isApplication || e.key !== 'governance/console') - .map((e) => this.renderExtension(e, true, query)); + .map((e) => renderExtension(e, true, query)); }; - renderExtensions = () => { - const query = this.getQuery(); - const extensions = this.props.component.extensions ?? []; + const renderExtensions = () => { + const query = getQuery(); const withoutSecurityExtension = extensions.filter( (extension) => !extension.key.startsWith('securityreport/') && !extension.key.startsWith('governance/') @@ -591,7 +537,7 @@ export class Menu extends React.PureComponent<Props> { id="component-navigation-more" size="auto" zLevel={PopupZLevel.Global} - overlay={withoutSecurityExtension.map((e) => this.renderExtension(e, false, query))} + overlay={withoutSecurityExtension.map((e) => renderExtension(e, false, query))} > {({ onToggleClick, open, a11yAttrs }) => ( <NavBarTabLink @@ -608,27 +554,25 @@ export class Menu extends React.PureComponent<Props> { ); }; - render() { - return ( - <div className="sw-flex sw-justify-between sw-pt-4 it__navbar-tabs"> - <NavBarTabs> - {this.renderDashboardLink()} - {this.renderBreakdownLink()} - {this.renderIssuesLink()} - {this.renderSecurityHotspotsLink()} - {this.renderSecurityReports()} - {this.renderComponentMeasuresLink()} - {this.renderCodeLink()} - {this.renderActivityLink()} - {this.renderExtensions()} - </NavBarTabs> - <NavBarTabs> - {this.renderAdministration()} - {this.renderProjectInformationButton()} - </NavBarTabs> - </div> - ); - } + return ( + <div className="sw-flex sw-justify-between sw-pt-4 it__navbar-tabs"> + <NavBarTabs> + {renderDashboardLink()} + {renderBreakdownLink()} + {renderIssuesLink()} + {renderSecurityHotspotsLink()} + {renderSecurityReports()} + {renderComponentMeasuresLink()} + {renderCodeLink()} + {renderActivityLink()} + {renderExtensions()} + </NavBarTabs> + <NavBarTabs> + {renderAdministration()} + {renderProjectInformationButton()} + </NavBarTabs> + </div> + ); } export default withAvailableFeatures(Menu); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/AnalysisErrorMessage-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/AnalysisErrorMessage-test.tsx index 77b79601c60..c7adb1be843 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/AnalysisErrorMessage-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/AnalysisErrorMessage-test.tsx @@ -19,29 +19,39 @@ */ import { screen } from '@testing-library/react'; import * as React from 'react'; +import BranchesServiceMock from '../../../../../api/mocks/BranchesServiceMock'; import { mockComponent } from '../../../../../helpers/mocks/component'; import { mockTask } from '../../../../../helpers/mocks/tasks'; import { renderApp } from '../../../../../helpers/testReactTestingUtils'; +import { Feature } from '../../../../../types/features'; import { AnalysisErrorMessage } from '../AnalysisErrorMessage'; +const handler = new BranchesServiceMock(); + +beforeEach(() => { + handler.reset(); +}); + it('should work when error is on a different branch', () => { renderAnalysisErrorMessage({ currentTask: mockTask({ branch: 'branch-1.2' }), - currentTaskOnSameBranch: false, }); expect(screen.getByText(/component_navigation.status.failed_branch_X/)).toBeInTheDocument(); expect(screen.getByText(/branch-1\.2/)).toBeInTheDocument(); }); -it('should work for errors on Pull Requests', () => { - renderAnalysisErrorMessage({ - currentTask: mockTask({ pullRequest: '2342', pullRequestTitle: 'Fix stuff' }), - currentTaskOnSameBranch: true, - }); +it('should work for errors on Pull Requests', async () => { + renderAnalysisErrorMessage( + { + currentTask: mockTask({ pullRequest: '01', pullRequestTitle: 'Fix stuff' }), + }, + undefined, + 'pullRequest=01&id=my-project' + ); - expect(screen.getByText(/component_navigation.status.failed_X/)).toBeInTheDocument(); - expect(screen.getByText(/2342 - Fix stuff/)).toBeInTheDocument(); + expect(await screen.findByText(/component_navigation.status.failed_X/)).toBeInTheDocument(); + expect(screen.getByText(/01 - Fix stuff/)).toBeInTheDocument(); }); it('should provide a link to admins', () => { @@ -67,7 +77,8 @@ it('should explain to admins how to get the staktrace', () => { function renderAnalysisErrorMessage( overrides: Partial<Parameters<typeof AnalysisErrorMessage>[0]> = {}, - location = '/' + location = '/', + params?: string ) { return renderApp( location, @@ -75,8 +86,8 @@ function renderAnalysisErrorMessage( component={mockComponent()} currentTask={mockTask()} onLeave={jest.fn()} - currentTaskOnSameBranch {...overrides} - /> + />, + { navigateTo: params ? `/?${params}` : undefined, featureList: [Feature.BranchSupport] } ); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx index 57bd0ba3d69..a3b5a039a84 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx @@ -21,19 +21,12 @@ import { screen } from '@testing-library/react'; import React from 'react'; import { mockProjectAlmBindingConfigurationErrors } from '../../../../../helpers/mocks/alm-settings'; import { mockComponent } from '../../../../../helpers/mocks/component'; -import { mockTask, mockTaskWarning } from '../../../../../helpers/mocks/tasks'; +import { mockTask } from '../../../../../helpers/mocks/tasks'; import { renderApp } from '../../../../../helpers/testReactTestingUtils'; import { ComponentQualifier } from '../../../../../types/component'; import { TaskStatuses } from '../../../../../types/tasks'; import ComponentNav, { ComponentNavProps } from '../ComponentNav'; -it('renders correctly when there are warnings', () => { - renderComponentNav({ warnings: [mockTaskWarning()] }); - expect( - screen.getByText('project_navigation.analysis_status.warnings', { exact: false }) - ).toBeInTheDocument(); -}); - it('renders correctly when there is a background task in progress', () => { renderComponentNav({ isInProgress: true }); expect( @@ -74,15 +67,11 @@ function renderComponentNav(props: Partial<ComponentNavProps> = {}) { return renderApp( '/', <ComponentNav - branchLikes={[]} component={mockComponent({ breadcrumbs: [{ key: 'foo', name: 'Foo', qualifier: ComponentQualifier.Project }], })} - currentBranchLike={undefined} isInProgress={false} isPending={false} - onWarningDismiss={jest.fn()} - warnings={[]} {...props} /> ); 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 4dbfe4e9a12..4b515761886 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,19 +20,15 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import BranchesServiceMock from '../../../../../api/mocks/BranchesServiceMock'; import { mockProjectAlmBindingResponse } from '../../../../../helpers/mocks/alm-settings'; -import { - mockMainBranch, - mockPullRequest, - mockSetOfBranchAndPullRequestForBranchSelector, -} from '../../../../../helpers/mocks/branch-like'; +import { mockMainBranch, mockPullRequest } from '../../../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../../../helpers/mocks/component'; import { mockCurrentUser, mockLoggedInUser } from '../../../../../helpers/testMocks'; import { renderApp } from '../../../../../helpers/testReactTestingUtils'; import { AlmKeys } from '../../../../../types/alm-settings'; import { ComponentQualifier } from '../../../../../types/component'; import { Feature } from '../../../../../types/features'; -import { BranchStatusContext } from '../../../branch-status/BranchStatusContext'; import { Header, HeaderProps } from '../Header'; jest.mock('../../../../../api/favorites', () => ({ @@ -40,34 +36,51 @@ jest.mock('../../../../../api/favorites', () => ({ removeFavorite: jest.fn().mockResolvedValue({}), })); -it('should render correctly when there is only 1 branch', () => { - renderHeader({ branchLikes: [mockMainBranch()] }); +const handler = new BranchesServiceMock(); + +beforeEach(() => handler.reset()); + +it('should render correctly when there is only 1 branch', async () => { + handler.emptyBranchesAndPullRequest(); + handler.addBranch(mockMainBranch({ status: { qualityGateStatus: 'OK' } })); + renderHeader(); + expect(await screen.findByLabelText('help-tooltip')).toBeInTheDocument(); expect(screen.getByText('project')).toBeInTheDocument(); - expect(screen.getByLabelText('help-tooltip')).toBeInTheDocument(); expect( - screen.getByRole('button', { name: 'branch-1 overview.quality_gate_x.OK' }) + await screen.findByRole('button', { name: 'master overview.quality_gate_x.OK' }) ).toBeDisabled(); }); it('should render correctly when there are multiple branch', async () => { const user = userEvent.setup(); renderHeader(); - expect(screen.getByRole('button', { name: 'branch-1 overview.quality_gate_x.OK' })).toBeEnabled(); + + expect( + await screen.findByRole('button', { name: 'main overview.quality_gate_x.OK' }) + ).toBeEnabled(); + expect(screen.queryByLabelText('help-tooltip')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'branch-1 overview.quality_gate_x.OK' })); + await user.click(screen.getByRole('button', { name: 'main overview.quality_gate_x.OK' })); expect(screen.getByText('branches.main_branch')).toBeInTheDocument(); expect( - screen.getByRole('menuitem', { name: 'branch-2 overview.quality_gate_x.ERROR ERROR' }) + screen.getByRole('menuitem', { + name: '03 – TEST-193 dumb commit overview.quality_gate_x.ERROR ERROR', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { + name: '01 – TEST-191 update master overview.quality_gate_x.OK OK', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: 'normal-branch overview.quality_gate_x.ERROR ERROR' }) ).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: 'branch-3' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: '1 – PR-1' })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: '2 – PR-2' })).toBeInTheDocument(); await user.click( - screen.getByRole('menuitem', { name: 'branch-2 overview.quality_gate_x.ERROR ERROR' }) + screen.getByRole('menuitem', { name: 'normal-branch overview.quality_gate_x.ERROR ERROR' }) ); - expect(screen.getByText('/dashboard?branch=branch-2&id=my-project')).toBeInTheDocument(); + expect(screen.getByText('/dashboard?branch=normal-branch&id=header-project')).toBeInTheDocument(); }); it('should show manage branch and pull request button for admin', async () => { @@ -75,16 +88,17 @@ it('should show manage branch and pull request button for admin', async () => { renderHeader({ currentUser: mockLoggedInUser(), component: mockComponent({ + key: 'header-project', configuration: { showSettings: true }, breadcrumbs: [{ name: 'project', key: 'project', qualifier: ComponentQualifier.Project }], }), }); - await user.click(screen.getByRole('button', { name: 'branch-1 overview.quality_gate_x.OK' })); + await user.click(await screen.findByRole('button', { name: 'main overview.quality_gate_x.OK' })); expect(screen.getByRole('link', { name: 'branch_like_navigation.manage' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'branch_like_navigation.manage' })).toHaveAttribute( 'href', - '/project/branches?id=my-project' + '/project/branches?id=header-project' ); }); @@ -104,45 +118,41 @@ it('should render favorite button if the user is logged in', async () => { it.each([['github'], ['gitlab'], ['bitbucket'], ['azure']])( 'should show correct %s links for a PR', - (alm: string) => { - renderHeader({ - currentUser: mockLoggedInUser(), - currentBranchLike: mockPullRequest({ - key: '1', - title: 'PR-1', - status: { qualityGateStatus: 'OK' }, - url: alm, - }), - branchLikes: [ - mockPullRequest({ - key: '1', - title: 'PR-1', - status: { qualityGateStatus: 'OK' }, - url: alm, - }), - ], - }); - const image = screen.getByAltText(alm); + async (alm: string) => { + handler.emptyBranchesAndPullRequest(); + handler.addPullRequest(mockPullRequest({ url: alm })); + renderHeader( + { + currentUser: mockLoggedInUser(), + }, + undefined, + 'pullRequest=1001&id=compa' + ); + const image = await screen.findByAltText(alm); expect(image).toBeInTheDocument(); expect(image).toHaveAttribute('src', `/images/alm/${alm}.svg`); } ); -it('should show the correct help tooltip for applications', () => { +it('should show the correct help tooltip for applications', async () => { + handler.emptyBranchesAndPullRequest(); + handler.addBranch(mockMainBranch()); renderHeader({ currentUser: mockLoggedInUser(), component: mockComponent({ + key: 'header-project', configuration: { showSettings: true }, breadcrumbs: [{ name: 'project', key: 'project', qualifier: ComponentQualifier.Application }], qualifier: 'APP', }), - branchLikes: [mockMainBranch()], }); - expect(screen.getByText('application.branches.help')).toBeInTheDocument(); + expect(await screen.findByText('application.branches.help')).toBeInTheDocument(); expect(screen.getByText('application.branches.link')).toBeInTheDocument(); }); -it('should show the correct help tooltip when branch support is not enabled', () => { +it('should show the correct help tooltip when branch support is not enabled', async () => { + handler.emptyBranchesAndPullRequest(); + handler.addBranch(mockMainBranch()); renderHeader( { currentUser: mockLoggedInUser(), @@ -154,47 +164,29 @@ it('should show the correct help tooltip when branch support is not enabled', () }, [] ); - expect(screen.getByText('branch_like_navigation.no_branch_support.title.mr')).toBeInTheDocument(); + expect( + await screen.findByText('branch_like_navigation.no_branch_support.title.mr') + ).toBeInTheDocument(); expect( screen.getByText('branch_like_navigation.no_branch_support.content_x.mr.alm.gitlab') ).toBeInTheDocument(); }); -function renderHeader(props?: Partial<HeaderProps>, featureList = [Feature.BranchSupport]) { - const branchLikes = mockSetOfBranchAndPullRequestForBranchSelector(); - +function renderHeader( + props?: Partial<HeaderProps>, + featureList = [Feature.BranchSupport], + params?: string +) { return renderApp( '/', - <BranchStatusContext.Provider - value={{ - branchStatusByComponent: { - 'my-project': { - 'branch-branch-1': { - status: 'OK', - }, - 'branch-branch-2': { - status: 'ERROR', - }, - }, - }, - fetchBranchStatus: () => { - /*noop*/ - }, - updateBranchStatus: () => { - /*noop*/ - }, - }} - > - <Header - branchLikes={branchLikes} - component={mockComponent({ - breadcrumbs: [{ name: 'project', key: 'project', qualifier: ComponentQualifier.Project }], - })} - currentBranchLike={branchLikes[0]} - currentUser={mockCurrentUser()} - {...props} - /> - </BranchStatusContext.Provider>, - { featureList } + <Header + component={mockComponent({ + key: 'header-project', + breadcrumbs: [{ name: 'project', key: 'project', qualifier: ComponentQualifier.Project }], + })} + currentUser={mockCurrentUser()} + {...props} + />, + { featureList, navigateTo: params ? `/?id=header-project&${params}` : '/?id=header-project' } ); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx index cb92dda3fa1..3530de943b6 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx @@ -20,23 +20,39 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { mockBranch, mockPullRequest } from '../../../../../helpers/mocks/branch-like'; +import { getAnalysisStatus } from '../../../../../api/ce'; +import BranchesServiceMock from '../../../../../api/mocks/BranchesServiceMock'; import { mockComponent } from '../../../../../helpers/mocks/component'; -import { mockTask, mockTaskWarning } from '../../../../../helpers/mocks/tasks'; +import { mockTask } from '../../../../../helpers/mocks/tasks'; import { mockCurrentUser, mockLoggedInUser } from '../../../../../helpers/testMocks'; import { renderApp } from '../../../../../helpers/testReactTestingUtils'; +import { Feature } from '../../../../../types/features'; import { TaskStatuses } from '../../../../../types/tasks'; import { CurrentUser } from '../../../../../types/users'; import HeaderMeta, { HeaderMetaProps } from '../HeaderMeta'; +jest.mock('../../../../../api/ce'); + +const handler = new BranchesServiceMock(); + +beforeEach(() => handler.reset()); + it('should render correctly for a branch with warnings', async () => { const user = userEvent.setup(); + jest.mocked(getAnalysisStatus).mockResolvedValue({ + component: { + warnings: [{ dismissable: false, key: 'key', message: 'bar' }], + key: 'compkey', + name: 'me', + }, + }); + renderHeaderMeta({}, undefined, 'branch=normal-branch&id=my-project'); - renderHeaderMeta(); - - expect(screen.getByText('version_x.0.0.1')).toBeInTheDocument(); + expect(await screen.findByText('version_x.0.0.1')).toBeInTheDocument(); - expect(screen.getByText('project_navigation.analysis_status.warnings')).toBeInTheDocument(); + expect( + await screen.findByText('project_navigation.analysis_status.warnings') + ).toBeInTheDocument(); await user.click(screen.getByText('project_navigation.analysis_status.details_link')); @@ -44,7 +60,14 @@ it('should render correctly for a branch with warnings', async () => { }); it('should handle a branch with missing version and no warnings', () => { - renderHeaderMeta({ component: mockComponent({ version: undefined }), warnings: [] }); + jest.mocked(getAnalysisStatus).mockResolvedValue({ + component: { + warnings: [], + key: 'compkey', + name: 'me', + }, + }); + renderHeaderMeta({ component: mockComponent({ version: undefined }) }); expect(screen.queryByText('version_x.0.0.1')).not.toBeInTheDocument(); expect(screen.queryByText('project_navigation.analysis_status.warnings')).not.toBeInTheDocument(); @@ -60,22 +83,20 @@ it('should render correctly with a failed analysis', async () => { }), }); - expect(screen.getByText('project_navigation.analysis_status.failed')).toBeInTheDocument(); + expect(await screen.findByText('project_navigation.analysis_status.failed')).toBeInTheDocument(); await user.click(screen.getByText('project_navigation.analysis_status.details_link')); expect(screen.getByRole('heading', { name: 'error' })).toBeInTheDocument(); }); -it('should render correctly for a pull request', () => { - renderHeaderMeta({ - branchLike: mockPullRequest({ - url: 'https://example.com/pull/1234', - }), - }); +it('should render correctly for a pull request', async () => { + renderHeaderMeta({}, undefined, 'pullRequest=01&id=my-project'); + expect( + await screen.findByText('branch_like_navigation.for_merge_into_x_from_y') + ).toBeInTheDocument(); expect(screen.queryByText('version_x.0.0.1')).not.toBeInTheDocument(); - expect(screen.getByText('branch_like_navigation.for_merge_into_x_from_y')).toBeInTheDocument(); }); it('should render correctly when the user is not logged in', () => { @@ -87,20 +108,12 @@ it('should render correctly when the user is not logged in', () => { function renderHeaderMeta( props: Partial<HeaderMetaProps> = {}, - currentUser: CurrentUser = mockLoggedInUser() + currentUser: CurrentUser = mockLoggedInUser(), + params?: string ) { - return renderApp( - '/', - <HeaderMeta - branchLike={mockBranch()} - component={mockComponent({ version: '0.0.1' })} - onWarningDismiss={jest.fn()} - warnings={[ - mockTaskWarning({ key: '1', message: 'ERROR_1' }), - mockTaskWarning({ key: '2', message: 'ERROR_2' }), - ]} - {...props} - />, - { currentUser } - ); + return renderApp('/', <HeaderMeta component={mockComponent({ version: '0.0.1' })} {...props} />, { + currentUser, + navigateTo: params ? `/?id=my-project&${params}` : '/?id=my-project', + featureList: [Feature.BranchSupport], + }); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx index 599299e7e7b..2add79f18e5 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx @@ -20,22 +20,24 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; -import { - mockBranch, - mockMainBranch, - mockPullRequest, -} from '../../../../../helpers/mocks/branch-like'; +import BranchesServiceMock from '../../../../../api/mocks/BranchesServiceMock'; import { mockComponent } from '../../../../../helpers/mocks/component'; import { renderComponent } from '../../../../../helpers/testReactTestingUtils'; +import { ComponentPropsType } from '../../../../../helpers/testUtils'; import { ComponentQualifier } from '../../../../../types/component'; +import { Feature } from '../../../../../types/features'; import { Menu } from '../Menu'; +const handler = new BranchesServiceMock(); + const BASE_COMPONENT = mockComponent({ analysisDate: '2019-12-01', key: 'foo', name: 'foo', }); +beforeEach(() => handler.reset()); + it('should render correctly', async () => { const user = userEvent.setup(); const component = { @@ -90,33 +92,37 @@ it('should render correctly when on a Portofolio', () => { expect(screen.getByRole('link', { name: 'portfolio_breakdown.page' })).toBeInTheDocument(); }); -it('should render correctly when on a branch', () => { - renderMenu({ - branchLike: mockBranch(), - component: { - ...BASE_COMPONENT, - configuration: { showSettings: true }, - extensions: [{ key: 'component-foo', name: 'ComponentFoo' }], +it('should render correctly when on a branch', async () => { + renderMenu( + { + component: { + ...BASE_COMPONENT, + configuration: { showSettings: true }, + extensions: [{ key: 'component-foo', name: 'ComponentFoo' }], + }, }, - }); + 'branch=normal-branch' + ); - expect(screen.getByRole('link', { name: 'overview.page' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'overview.page' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'issues.page' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'layout.measures' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'project.info.title' })).toBeInTheDocument(); }); -it('should render correctly when on a pull request', () => { - renderMenu({ - branchLike: mockPullRequest(), - component: { - ...BASE_COMPONENT, - configuration: { showSettings: true }, - extensions: [{ key: 'component-foo', name: 'ComponentFoo' }], +it('should render correctly when on a pull request', async () => { + renderMenu( + { + component: { + ...BASE_COMPONENT, + configuration: { showSettings: true }, + extensions: [{ key: 'component-foo', name: 'ComponentFoo' }], + }, }, - }); + 'pullRequest=01' + ); - expect(screen.getByRole('link', { name: 'overview.page' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'overview.page' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'issues.page' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'layout.measures' })).toBeInTheDocument(); @@ -153,19 +159,16 @@ it('should disable links if application has inaccessible projects', () => { expect(screen.queryByRole('button', { name: 'application.info.title' })).not.toBeInTheDocument(); }); -function renderMenu(props: Partial<Menu['props']> = {}) { - const mainBranch = mockMainBranch(); +function renderMenu(props: Partial<ComponentPropsType<typeof Menu>> = {}, params?: string) { return renderComponent( <Menu hasFeature={jest.fn().mockReturnValue(false)} - branchLike={mainBranch} - branchLikes={[mainBranch]} component={BASE_COMPONENT} isInProgress={false} isPending={false} - onToggleProjectInfo={jest.fn()} - projectInfoDisplayed={false} {...props} - /> + />, + params ? `/?${params}` : '/', + { featureList: [Feature.BranchSupport] } ); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx index 4c6d2c54525..3b20d3b6f89 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx @@ -22,8 +22,8 @@ import * as React from 'react'; import EscKeydownHandler from '../../../../../components/controls/EscKeydownHandler'; 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 { BranchLike } from '../../../../../types/branch-like'; import { ComponentQualifier } from '../../../../../types/component'; import { Feature } from '../../../../../types/features'; import { Component } from '../../../../../types/types'; @@ -36,91 +36,95 @@ import Menu from './Menu'; import PRLink from './PRLink'; export interface BranchLikeNavigationProps extends WithAvailableFeaturesProps { - branchLikes: BranchLike[]; component: Component; - currentBranchLike: BranchLike; projectBinding?: ProjectAlmBindingResponse; } export function BranchLikeNavigation(props: BranchLikeNavigationProps) { const { - branchLikes, component, component: { configuration }, - currentBranchLike, projectBinding, } = props; + const { data: { branchLikes, branchLike: currentBranchLike } = { branchLikes: [] } } = + useBranchesQuery(component); + const [isMenuOpen, setIsMenuOpen] = React.useState(false); + + if (currentBranchLike === undefined) { + return null; + } + const isApplication = component.qualifier === ComponentQualifier.Application; const isGitLab = projectBinding !== undefined && projectBinding.alm === AlmKeys.GitLab; - const [isMenuOpen, setIsMenuOpen] = React.useState(false); const branchSupportEnabled = props.hasFeature(Feature.BranchSupport); const canAdminComponent = configuration?.showSettings; const hasManyBranches = branchLikes.length >= 2; const isMenuEnabled = branchSupportEnabled && hasManyBranches; - const currentBranchLikeElement = ( - <CurrentBranchLike component={component} currentBranchLike={currentBranchLike} /> - ); + const currentBranchLikeElement = <CurrentBranchLike currentBranchLike={currentBranchLike} />; const handleOutsideClick = () => { setIsMenuOpen(false); }; return ( - <div className="sw-flex sw-items-center it__branch-like-navigation-toggler-container"> - <Popup - allowResizing - overlay={ - isMenuOpen && ( - <FocusOutHandler onFocusOut={handleOutsideClick}> - <EscKeydownHandler onKeydown={handleOutsideClick}> - <OutsideClickHandler onClickOutside={handleOutsideClick}> - <Menu - branchLikes={branchLikes} - canAdminComponent={canAdminComponent} - component={component} - currentBranchLike={currentBranchLike} - onClose={() => { - setIsMenuOpen(false); - }} - /> - </OutsideClickHandler> - </EscKeydownHandler> - </FocusOutHandler> - ) - } - placement={PopupPlacement.BottomLeft} - zLevel={PopupZLevel.Global} - > - <ButtonSecondary - className="sw-max-w-abs-350 sw-px-3" - onClick={() => { - setIsMenuOpen(!isMenuOpen); - }} - disabled={!isMenuEnabled} - aria-expanded={isMenuOpen} - aria-haspopup="menu" + <> + <span className="slash-separator sw-mx-2" /> + <div className="sw-flex sw-items-center it__branch-like-navigation-toggler-container"> + <Popup + allowResizing + overlay={ + isMenuOpen && ( + <FocusOutHandler onFocusOut={handleOutsideClick}> + <EscKeydownHandler onKeydown={handleOutsideClick}> + <OutsideClickHandler onClickOutside={handleOutsideClick}> + <Menu + branchLikes={branchLikes} + canAdminComponent={canAdminComponent} + component={component} + currentBranchLike={currentBranchLike} + onClose={() => { + setIsMenuOpen(false); + }} + /> + </OutsideClickHandler> + </EscKeydownHandler> + </FocusOutHandler> + ) + } + placement={PopupPlacement.BottomLeft} + zLevel={PopupZLevel.Global} > - {currentBranchLikeElement} - </ButtonSecondary> - </Popup> + <ButtonSecondary + className="sw-max-w-abs-350 sw-px-3" + onClick={() => { + setIsMenuOpen(!isMenuOpen); + }} + disabled={!isMenuEnabled} + aria-expanded={isMenuOpen} + aria-haspopup="menu" + > + {currentBranchLikeElement} + </ButtonSecondary> + </Popup> - <div className="sw-ml-2"> - <BranchHelpTooltip - component={component} - isApplication={isApplication} - projectBinding={projectBinding} - hasManyBranches={hasManyBranches} - canAdminComponent={canAdminComponent} - branchSupportEnabled={branchSupportEnabled} - isGitLab={isGitLab} - /> - </div> + <div className="sw-ml-2"> + <BranchHelpTooltip + component={component} + isApplication={isApplication} + projectBinding={projectBinding} + hasManyBranches={hasManyBranches} + canAdminComponent={canAdminComponent} + branchSupportEnabled={branchSupportEnabled} + isGitLab={isGitLab} + /> + </div> - <PRLink currentBranchLike={currentBranchLike} component={component} /> - </div> + <PRLink currentBranchLike={currentBranchLike} component={component} /> + </div> + </> ); } diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx index 2de026da87e..e3bc2c89a41 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx @@ -22,16 +22,14 @@ import * as React from 'react'; import BranchLikeIcon from '../../../../../components/icons/BranchLikeIcon'; import { getBranchLikeDisplayName } from '../../../../../helpers/branch-like'; import { BranchLike, BranchStatusData } from '../../../../../types/branch-like'; -import { Component } from '../../../../../types/types'; import QualityGateStatus from './QualityGateStatus'; export interface CurrentBranchLikeProps extends Pick<BranchStatusData, 'status'> { - component: Component; currentBranchLike: BranchLike; } export function CurrentBranchLike(props: CurrentBranchLikeProps) { - const { component, currentBranchLike } = props; + const { currentBranchLike } = props; const displayName = getBranchLikeDisplayName(currentBranchLike); @@ -39,7 +37,7 @@ export function CurrentBranchLike(props: CurrentBranchLikeProps) { <div className="sw-flex sw-items-center text-ellipsis"> <BranchLikeIcon branchLike={currentBranchLike} /> <TextMuted text={displayName} className="sw-ml-3" /> - <QualityGateStatus branchLike={currentBranchLike} component={component} className="sw-ml-4" /> + <QualityGateStatus branchLike={currentBranchLike} className="sw-ml-4" /> <ChevronDownIcon className="sw-ml-1" /> </div> ); diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/Menu.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/Menu.tsx index ea1bfbc48f6..777f2d999fd 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/Menu.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/Menu.tsx @@ -176,7 +176,6 @@ export class Menu extends React.PureComponent<Props, State> { /> <MenuItemList branchLikeTree={branchLikesToDisplayTree} - component={component} hasResults={hasResults} onSelect={this.handleOnSelect} selectedBranchLike={selectedBranchLike} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItem.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItem.tsx index cc9b8644d52..dff3e81a1cd 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItem.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItem.tsx @@ -24,12 +24,10 @@ import BranchLikeIcon from '../../../../../components/icons/BranchLikeIcon'; import { getBranchLikeDisplayName, isMainBranch } from '../../../../../helpers/branch-like'; import { translate } from '../../../../../helpers/l10n'; import { BranchLike } from '../../../../../types/branch-like'; -import { Component } from '../../../../../types/types'; import QualityGateStatus from './QualityGateStatus'; export interface MenuItemProps { branchLike: BranchLike; - component: Component; onSelect: (branchLike: BranchLike) => void; selected: boolean; indent: boolean; @@ -37,7 +35,7 @@ export interface MenuItemProps { } export function MenuItem(props: MenuItemProps) { - const { branchLike, component, setSelectedNode, onSelect, selected, indent } = props; + const { branchLike, setSelectedNode, onSelect, selected, indent } = props; const displayName = getBranchLikeDisplayName(branchLike); return ( @@ -64,7 +62,6 @@ export function MenuItem(props: MenuItemProps) { </div> <QualityGateStatus branchLike={branchLike} - component={component} className="sw-flex sw-items-center sw-w-24" showStatusText /> diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItemList.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItemList.tsx index 93ea2516d1f..c91f6b744b7 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItemList.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItemList.tsx @@ -24,12 +24,10 @@ import { getBranchLikeKey, isSameBranchLike } from '../../../../../helpers/branc import { translate } from '../../../../../helpers/l10n'; import { isDefined } from '../../../../../helpers/types'; import { BranchLike, BranchLikeTree } from '../../../../../types/branch-like'; -import { Component } from '../../../../../types/types'; import MenuItem from './MenuItem'; export interface MenuItemListProps { branchLikeTree: BranchLikeTree; - component: Component; hasResults: boolean; onSelect: (branchLike: BranchLike) => void; selectedBranchLike: BranchLike | undefined; @@ -45,12 +43,11 @@ export function MenuItemList(props: MenuItemListProps) { } }); - const { branchLikeTree, component, hasResults, onSelect, selectedBranchLike } = props; + const { branchLikeTree, hasResults, onSelect, selectedBranchLike } = props; const renderItem = (branchLike: BranchLike, indent = false) => ( <MenuItem branchLike={branchLike} - component={component} key={getBranchLikeKey(branchLike)} onSelect={onSelect} selected={isSameBranchLike(branchLike, selectedBranchLike)} diff --git a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/QualityGateStatus.tsx b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/QualityGateStatus.tsx index 861f7c84ea3..8ba0149febf 100644 --- a/server/sonar-web/src/main/js/app/components/nav/component/branch-like/QualityGateStatus.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/component/branch-like/QualityGateStatus.tsx @@ -19,45 +19,34 @@ */ import classNames from 'classnames'; import { QualityGateIndicator } from 'design-system'; -import React, { useContext } from 'react'; -import { getBranchStatusByBranchLike } from '../../../../../helpers/branch-like'; +import React from 'react'; import { translateWithParameters } from '../../../../../helpers/l10n'; import { formatMeasure } from '../../../../../helpers/measures'; import { BranchLike } from '../../../../../types/branch-like'; import { MetricType } from '../../../../../types/metrics'; -import { Component } from '../../../../../types/types'; -import { BranchStatusContext } from '../../../branch-status/BranchStatusContext'; interface Props { - component: Component; branchLike: BranchLike; className: string; showStatusText?: boolean; } -export default function QualityGateStatus({ - component, - branchLike, - className, - showStatusText, -}: Props) { - const { branchStatusByComponent } = useContext(BranchStatusContext); - const branchStatus = getBranchStatusByBranchLike( - branchStatusByComponent, - component.key, - branchLike - ); - +export default function QualityGateStatus({ className, showStatusText, branchLike }: Props) { // eslint-disable-next-line @typescript-eslint/prefer-optional-chain, @typescript-eslint/no-unnecessary-condition - if (!branchStatus || !branchStatus.status) { + if (!branchLike.status?.qualityGateStatus) { return null; } - const { status } = branchStatus; - const formatted = formatMeasure(status, MetricType.Level); + + const formatted = formatMeasure(branchLike.status?.qualityGateStatus, MetricType.Level); const ariaLabel = translateWithParameters('overview.quality_gate_x', formatted); return ( - <div className={classNames(`it__level-${status}`, className)}> - <QualityGateIndicator status={status} className="sw-mr-2" ariaLabel={ariaLabel} size="sm" /> + <div className={classNames(`it__level-${branchLike.status.qualityGateStatus}`, className)}> + <QualityGateIndicator + status={branchLike.status?.qualityGateStatus} + className="sw-mr-2" + ariaLabel={ariaLabel} + size="sm" + /> {showStatusText && <span>{formatted}</span>} </div> ); diff --git a/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx b/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx index 003dd7c0525..f71537e379b 100644 --- a/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx +++ b/server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx @@ -27,9 +27,7 @@ import { CurrentUserContextInterface } from '../current-user/CurrentUserContext' import withCurrentUserContext from '../current-user/withCurrentUserContext'; import './PromotionNotification.css'; -export interface PromotionNotificationProps extends CurrentUserContextInterface {} - -export function PromotionNotification(props: PromotionNotificationProps) { +export function PromotionNotification(props: CurrentUserContextInterface) { const { currentUser } = props; if (!isLoggedIn(currentUser) || currentUser.dismissedNotices[NoticeType.SONARLINT_AD]) { diff --git a/server/sonar-web/src/main/js/app/components/promotion-notification/__tests__/PromotionNotification-test.tsx b/server/sonar-web/src/main/js/app/components/promotion-notification/__tests__/PromotionNotification-test.tsx index cd11a4f17d2..4f30edf3a9c 100644 --- a/server/sonar-web/src/main/js/app/components/promotion-notification/__tests__/PromotionNotification-test.tsx +++ b/server/sonar-web/src/main/js/app/components/promotion-notification/__tests__/PromotionNotification-test.tsx @@ -23,7 +23,8 @@ import { dismissNotice } from '../../../../api/users'; import { mockCurrentUser, mockLoggedInUser } from '../../../../helpers/testMocks'; import { waitAndUpdate } from '../../../../helpers/testUtils'; import { NoticeType } from '../../../../types/users'; -import { PromotionNotification, PromotionNotificationProps } from '../PromotionNotification'; +import { CurrentUserContextInterface } from '../../current-user/CurrentUserContext'; +import { PromotionNotification } from '../PromotionNotification'; jest.mock('../../../../api/users', () => ({ dismissNotice: jest.fn().mockResolvedValue({}), @@ -67,7 +68,7 @@ it('should remove the toaster and navigate to sonarlint when click on learn more expect(updateDismissedNotices).toHaveBeenCalled(); }); -function shallowRender(props: Partial<PromotionNotificationProps> = {}) { +function shallowRender(props: Partial<CurrentUserContextInterface> = {}) { return shallow( <PromotionNotification currentUser={mockCurrentUser()} diff --git a/server/sonar-web/src/main/js/components/common/AnalysisWarningsModal.tsx b/server/sonar-web/src/main/js/apps/background-tasks/components/AnalysisWarningsModal.tsx index 5ec98f9c259..c585b9a8da3 100644 --- a/server/sonar-web/src/main/js/components/common/AnalysisWarningsModal.tsx +++ b/server/sonar-web/src/main/js/apps/background-tasks/components/AnalysisWarningsModal.tsx @@ -25,20 +25,18 @@ import { Modal, } from 'design-system'; import * as React from 'react'; -import { dismissAnalysisWarning, getTask } from '../../api/ce'; -import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; -import { translate } from '../../helpers/l10n'; -import { sanitizeStringRestricted } from '../../helpers/sanitize'; -import { TaskWarning } from '../../types/tasks'; -import { CurrentUser } from '../../types/users'; +import { dismissAnalysisWarning, getTask } from '../../../api/ce'; +import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; +import { translate } from '../../../helpers/l10n'; +import { sanitizeStringRestricted } from '../../../helpers/sanitize'; +import { TaskWarning } from '../../../types/tasks'; +import { CurrentUser } from '../../../types/users'; interface Props { componentKey?: string; currentUser: CurrentUser; onClose: () => void; - onWarningDismiss?: () => void; - taskId?: string; - warnings?: TaskWarning[]; + taskId: string; } interface State { @@ -53,24 +51,20 @@ export class AnalysisWarningsModal extends React.PureComponent<Props, State> { constructor(props: Props) { super(props); this.state = { - loading: !props.warnings, - warnings: props.warnings || [], + loading: false, + warnings: [], }; } componentDidMount() { this.mounted = true; - if (!this.props.warnings && this.props.taskId) { - this.loadWarnings(this.props.taskId); - } + this.loadWarnings(this.props.taskId); } componentDidUpdate(prevProps: Props) { - const { taskId, warnings } = this.props; - if (!warnings && taskId && prevProps.taskId !== taskId) { + const { taskId } = this.props; + if (prevProps.taskId !== taskId) { this.loadWarnings(taskId); - } else if (warnings && prevProps.warnings !== warnings) { - this.setState({ warnings }); } } @@ -86,13 +80,8 @@ export class AnalysisWarningsModal extends React.PureComponent<Props, State> { } this.setState({ dismissedWarning: messageKey }); - try { await dismissAnalysisWarning(componentKey, messageKey); - - if (this.props.onWarningDismiss) { - this.props.onWarningDismiss(); - } } catch (e) { // Noop } diff --git a/server/sonar-web/src/main/js/apps/background-tasks/components/TaskActions.tsx b/server/sonar-web/src/main/js/apps/background-tasks/components/TaskActions.tsx index 4234161e1b6..e3533e0b197 100644 --- a/server/sonar-web/src/main/js/apps/background-tasks/components/TaskActions.tsx +++ b/server/sonar-web/src/main/js/apps/background-tasks/components/TaskActions.tsx @@ -18,11 +18,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import AnalysisWarningsModal from '../../../components/common/AnalysisWarningsModal'; import ActionsDropdown, { ActionsDropdownItem } from '../../../components/controls/ActionsDropdown'; import ConfirmModal from '../../../components/controls/ConfirmModal'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { Task, TaskStatuses } from '../../../types/tasks'; +import AnalysisWarningsModal from './AnalysisWarningsModal'; import ScannerContext from './ScannerContext'; import Stacktrace from './Stacktrace'; diff --git a/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx b/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx index af5d7b5255f..25845bd1ccb 100644 --- a/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx @@ -17,17 +17,15 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { debounce, noop } from 'lodash'; import * as React from 'react'; -import withBranchStatusActions from '../../../app/components/branch-status/withBranchStatusActions'; import withComponentContext from '../../../app/components/componentContext/withComponentContext'; import withMetricsContext from '../../../app/components/metrics/withMetricsContext'; import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; -import { isPullRequest } from '../../../helpers/branch-like'; import { CodeScope, getCodeUrl, getProjectUrl } from '../../../helpers/urls'; +import { withBranchLikes } from '../../../queries/branch'; import { BranchLike } from '../../../types/branch-like'; import { ComponentQualifier } from '../../../types/component'; -import { Breadcrumb, Component, ComponentMeasure, Dict, Issue, Metric } from '../../../types/types'; +import { Breadcrumb, Component, ComponentMeasure, Dict, Metric } from '../../../types/types'; import { addComponent, addComponentBreadcrumbs, clearBucket } from '../bucket'; import '../code.css'; import { loadMoreChildren, retrieveComponent, retrieveComponentChildren } from '../utils'; @@ -35,8 +33,8 @@ import CodeAppRenderer from './CodeAppRenderer'; interface Props { branchLike?: BranchLike; + branchLikes: BranchLike[]; component: Component; - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => Promise<void>; location: Location; router: Router; metrics: Dict<Metric>; @@ -68,7 +66,6 @@ class CodeApp extends React.Component<Props, State> { total: 0, newCodeSelected: true, }; - this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000); } componentDidMount() { @@ -184,10 +181,6 @@ class CodeApp extends React.Component<Props, State> { this.setState({ highlighted }); }; - handleIssueChange = (_: Issue) => { - this.refreshBranchStatus(); - }; - handleSearchClear = () => { this.setState({ searchResults: undefined }); }; @@ -223,13 +216,6 @@ class CodeApp extends React.Component<Props, State> { this.loadComponent(finalKey); }; - refreshBranchStatus = () => { - const { branchLike, component } = this.props; - if (branchLike && component && isPullRequest(branchLike)) { - this.props.fetchBranchStatus(branchLike, component.key).catch(noop); - } - }; - render() { return ( <CodeAppRenderer @@ -237,7 +223,6 @@ class CodeApp extends React.Component<Props, State> { {...this.state} handleGoToParent={this.handleGoToParent} handleHighlight={this.handleHighlight} - handleIssueChange={this.handleIssueChange} handleLoadMore={this.handleLoadMore} handleSearchClear={this.handleSearchClear} handleSearchResults={this.handleSearchResults} @@ -248,6 +233,4 @@ class CodeApp extends React.Component<Props, State> { } } -export default withRouter( - withComponentContext(withBranchStatusActions(withMetricsContext(CodeApp))) -); +export default withRouter(withComponentContext(withMetricsContext(withBranchLikes(CodeApp)))); diff --git a/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx b/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx index 67fc4ee4697..d7ea12754eb 100644 --- a/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx @@ -38,7 +38,7 @@ import { KeyboardKeys } from '../../../helpers/keycodes'; import { translate } from '../../../helpers/l10n'; import { BranchLike } from '../../../types/branch-like'; import { isApplication, isPortfolioLike } from '../../../types/component'; -import { Breadcrumb, Component, ComponentMeasure, Dict, Issue, Metric } from '../../../types/types'; +import { Breadcrumb, Component, ComponentMeasure, Dict, Metric } from '../../../types/types'; import '../code.css'; import { getCodeMetrics } from '../utils'; import CodeBreadcrumbs from './CodeBreadcrumbs'; @@ -63,7 +63,6 @@ interface Props { handleGoToParent: () => void; handleHighlight: (highlighted: ComponentMeasure) => void; - handleIssueChange: (issue: Issue) => void; handleLoadMore: () => void; handleSearchClear: () => void; handleSearchResults: (searchResults: ComponentMeasure[]) => void; @@ -230,7 +229,6 @@ export default function CodeAppRenderer(props: Props) { isFile location={location} onGoToParent={props.handleGoToParent} - onIssueChange={props.handleIssueChange} /> </div> )} diff --git a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx index e1f557e7825..652a41f6837 100644 --- a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx @@ -18,18 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import SourceViewer from '../../../components/SourceViewer/SourceViewer'; import withKeyboardNavigation from '../../../components/hoc/withKeyboardNavigation'; import { Location } from '../../../components/hoc/withRouter'; -import SourceViewer from '../../../components/SourceViewer/SourceViewer'; import { BranchLike } from '../../../types/branch-like'; -import { Issue, Measure } from '../../../types/types'; +import { Measure } from '../../../types/types'; export interface SourceViewerWrapperProps { branchLike?: BranchLike; component: string; componentMeasures: Measure[] | undefined; location: Location; - onIssueChange?: (issue: Issue) => void; } function SourceViewerWrapper(props: SourceViewerWrapperProps) { @@ -53,7 +52,6 @@ function SourceViewerWrapper(props: SourceViewerWrapperProps) { component={component} componentMeasures={componentMeasures} highlightedLine={finalLine} - onIssueChange={props.onIssueChange} onLoaded={handleLoaded} showMeasures /> diff --git a/server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx b/server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx index 05d8be68ed6..c235053b9b9 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx @@ -21,15 +21,16 @@ import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { times } from 'lodash'; import selectEvent from 'react-select-event'; +import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import ComponentsServiceMock from '../../../api/mocks/ComponentsServiceMock'; import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock'; import { MeasuresServiceMock } from '../../../api/mocks/MeasuresServiceMock'; -import { mockPullRequest } from '../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../helpers/mocks/component'; import { mockMeasure, mockMetric } from '../../../helpers/testMocks'; import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byTestId, byText } from '../../../helpers/testSelector'; import { ComponentContextShape, ComponentQualifier } from '../../../types/component'; +import { Feature } from '../../../types/features'; import { MetricKey } from '../../../types/metrics'; import routes from '../routes'; @@ -46,11 +47,13 @@ jest.mock('../../../api/metrics', () => { const componentsHandler = new ComponentsServiceMock(); const measuresHandler = new MeasuresServiceMock(); const issuesHandler = new IssuesServiceMock(); +const branchHandler = new BranchesServiceMock(); afterEach(() => { componentsHandler.reset(); measuresHandler.reset(); issuesHandler.reset(); + branchHandler.reset(); }); describe('rendering', () => { @@ -144,12 +147,10 @@ describe('rendering', () => { it('should render correctly if on a pull request and viewing coverage', async () => { const { ui } = getPageObject(); - renderMeasuresApp('component_measures?id=foo&metric=coverage&pullRequest=1', { - branchLike: mockPullRequest({ key: '1' }), - }); + renderMeasuresApp('component_measures?id=foo&metric=coverage&pullRequest=01'); await ui.appLoaded(); - expect(ui.detailsUnavailableText.get()).toBeInTheDocument(); + expect(await ui.detailsUnavailableText.find()).toBeInTheDocument(); }); it('should render a warning message if the user does not have access to all components', async () => { @@ -538,7 +539,7 @@ function renderMeasuresApp(navigateTo?: string, componentContext?: Partial<Compo return renderAppWithComponentContext( 'component_measures', routes, - { navigateTo }, + { navigateTo, featureList: [Feature.BranchSupport] }, { component: mockComponent({ key: 'foo' }), ...componentContext } ); } diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx index 13aaf746887..33a9263ee3c 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx @@ -27,12 +27,11 @@ import { themeBorder, themeColor, } from 'design-system'; -import { debounce, keyBy } from 'lodash'; +import { keyBy } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import { getMeasuresWithPeriod } from '../../../api/measures'; import { getAllMetrics } from '../../../api/metrics'; -import withBranchStatusActions from '../../../app/components/branch-status/withBranchStatusActions'; import { ComponentContext } from '../../../app/components/componentContext/ComponentContext'; import Suggestions from '../../../components/embed-docs-modal/Suggestions'; import { Location, Router, withRouter } from '../../../components/hoc/withRouter'; @@ -40,18 +39,12 @@ import { enhanceMeasure } from '../../../components/measure/utils'; import '../../../components/search-navigator.css'; import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; +import { useBranchesQuery } from '../../../queries/branch'; import { BranchLike } from '../../../types/branch-like'; import { ComponentQualifier } from '../../../types/component'; import { MeasurePageView } from '../../../types/measures'; import { MetricKey } from '../../../types/metrics'; -import { - ComponentMeasure, - Dict, - Issue, - MeasureEnhanced, - Metric, - Period, -} from '../../../types/types'; +import { ComponentMeasure, Dict, MeasureEnhanced, Metric, Period } from '../../../types/types'; import Sidebar from '../sidebar/Sidebar'; import '../style.css'; import { @@ -74,7 +67,6 @@ import MeasuresEmpty from './MeasuresEmpty'; interface Props { branchLike?: BranchLike; component: ComponentMeasure; - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => Promise<void>; location: Location; router: Router; } @@ -97,7 +89,6 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> { measures: [], metrics: {}, }; - this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000); } componentDidMount() { @@ -180,10 +171,6 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> { return metric; }; - handleIssueChange = (_: Issue) => { - this.refreshBranchStatus(); - }; - updateQuery = (newQuery: Partial<Query>) => { const query: Query = { ...parseQuery(this.props.location.query), ...newQuery }; @@ -206,13 +193,6 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> { }); }; - refreshBranchStatus = () => { - const { branchLike, component } = this.props; - if (branchLike && component && isPullRequest(branchLike)) { - this.props.fetchBranchStatus(branchLike, component.key); - } - }; - renderContent = (displayOverview: boolean, query: Query, metric?: Metric) => { const { branchLike, component } = this.props; const { leakPeriod } = this.state; @@ -225,7 +205,6 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> { domain={query.metric} leakPeriod={leakPeriod} metrics={this.state.metrics} - onIssueChange={this.handleIssueChange} rootComponent={component} router={this.props.router} selected={query.selected} @@ -261,7 +240,6 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> { branchLike={branchLike} leakPeriod={leakPeriod} metrics={this.state.metrics} - onIssueChange={this.handleIssueChange} requestedMetric={metric} rootComponent={component} router={this.props.router} @@ -323,10 +301,11 @@ class ComponentMeasuresApp extends React.PureComponent<Props, State> { * is that we can't use the usual withComponentContext HOC, because the type * of `component` isn't the same. It probably used to work because of the lazy loading */ -const WrappedApp = withRouter(withBranchStatusActions(ComponentMeasuresApp)); +const WrappedApp = withRouter(ComponentMeasuresApp); function AppWithComponentContext() { - const { branchLike, component } = React.useContext(ComponentContext); + const { component } = React.useContext(ComponentContext); + const { data: { branchLike } = {} } = useBranchesQuery(component); return <WrappedApp branchLike={branchLike} component={component as ComponentMeasure} />; } diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx index 19e3a95ca0c..f055182097d 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx @@ -42,7 +42,6 @@ import { ComponentMeasureEnhanced, ComponentMeasureIntern, Dict, - Issue, Measure, Metric, Paging, @@ -62,7 +61,6 @@ interface Props { leakPeriod?: Period; requestedMetric: Pick<Metric, 'key' | 'direction'>; metrics: Dict<Metric>; - onIssueChange?: (issue: Issue) => void; rootComponent: ComponentMeasure; router: Router; selected?: string; @@ -438,7 +436,6 @@ export default class MeasureContent extends React.PureComponent<Props, State> { branchLike={branchLike} component={baseComponent.key} metricKey={this.state.metric?.key} - onIssueChange={this.props.onIssueChange} /> </div> ) : ( diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx index 8fb3ba81a25..95a8382f216 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx @@ -30,7 +30,6 @@ import { ComponentMeasureEnhanced, ComponentMeasureIntern, Dict, - Issue, Metric, Paging, Period, @@ -49,7 +48,6 @@ interface Props { leakPeriod?: Period; loading: boolean; metrics: Dict<Metric>; - onIssueChange?: (issue: Issue) => void; rootComponent: ComponentMeasure; updateLoading: (param: Dict<boolean>) => void; updateSelected: (component: ComponentMeasureIntern) => void; @@ -127,12 +125,7 @@ export default class MeasureOverview extends React.PureComponent<Props, State> { if (isFile) { return ( <div className="measure-details-viewer"> - <SourceViewer - hideHeader - branchLike={branchLike} - component={component.key} - onIssueChange={this.props.onIssueChange} - /> + <SourceViewer hideHeader branchLike={branchLike} component={component.key} /> </div> ); } diff --git a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx index 51667902957..f97567175bf 100644 --- a/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx +++ b/server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx @@ -28,7 +28,6 @@ import { ComponentMeasure, ComponentMeasureIntern, Dict, - Issue, Metric, Period, } from '../../../types/types'; @@ -41,7 +40,6 @@ interface Props { domain: string; leakPeriod?: Period; metrics: Dict<Metric>; - onIssueChange?: (issue: Issue) => void; rootComponent: ComponentMeasure; router: Router; selected?: string; @@ -135,7 +133,6 @@ export default class MeasureOverviewContainer extends React.PureComponent<Props, leakPeriod={this.props.leakPeriod} loading={this.state.loading.component || this.state.loading.bubbles} metrics={this.props.metrics} - onIssueChange={this.props.onIssueChange} rootComponent={this.props.rootComponent} updateLoading={this.updateLoading} updateSelected={this.updateSelected} diff --git a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx index e3e6645d3d8..8206deb02b6 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx @@ -42,6 +42,7 @@ import IssueTypeIcon from '../../../components/icon-mappers/IssueTypeIcon'; import { SEVERITIES } from '../../../helpers/constants'; import { throwGlobalError } from '../../../helpers/error'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { withBranchStatusRefresh } from '../../../queries/branch'; import { IssueSeverity } from '../../../types/issues'; import { Dict, Issue, IssueType, Paging } from '../../../types/types'; import AssigneeSelect from './AssigneeSelect'; @@ -51,6 +52,7 @@ interface Props { fetchIssues: (x: {}) => Promise<{ issues: Issue[]; paging: Paging }>; onClose: () => void; onDone: () => void; + refreshBranchStatus: () => void; } interface FormFields { @@ -84,7 +86,7 @@ enum InputField { export const MAX_PAGE_SIZE = 500; -export default class BulkChangeModal extends React.PureComponent<Props, State> { +export class BulkChangeModal extends React.PureComponent<Props, State> { mounted = false; constructor(props: Props) { @@ -185,6 +187,7 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> { bulkChangeIssues(issueKeys, query).then( () => { this.setState({ submitting: false }); + this.props.refreshBranchStatus(); this.props.onDone(); }, (error) => { @@ -499,3 +502,5 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> { function hasAction(action: string) { return (issue: Issue) => issue.actions && issue.actions.includes(action); } + +export default withBranchStatusRefresh(BulkChangeModal); diff --git a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx index 9cb91a60ee3..3d38005558c 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx @@ -31,13 +31,12 @@ import { themeBorder, themeColor, } from 'design-system'; -import { debounce, keyBy, omit, without } from 'lodash'; +import { keyBy, omit, without } from 'lodash'; import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import { FormattedMessage } from 'react-intl'; import { searchIssues } from '../../../api/issues'; import { getRuleDetails } from '../../../api/rules'; -import withBranchStatusActions from '../../../app/components/branch-status/withBranchStatusActions'; import withComponentContext from '../../../app/components/componentContext/withComponentContext'; import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; import { PageContext } from '../../../app/components/indexation/PageUnavailableDueToIndexation'; @@ -51,12 +50,7 @@ import { Location, Router, withRouter } from '../../../components/hoc/withRouter import IssueTabViewer from '../../../components/rules/IssueTabViewer'; import '../../../components/search-navigator.css'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; -import { - fillBranchLike, - getBranchLikeQuery, - isPullRequest, - isSameBranchLike, -} from '../../../helpers/branch-like'; +import { fillBranchLike, getBranchLikeQuery, isSameBranchLike } from '../../../helpers/branch-like'; import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication'; import { parseIssueFromResponse } from '../../../helpers/issues'; import { isDatePicker, isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; @@ -69,6 +63,7 @@ import { removeWhitePageClass, } from '../../../helpers/pages'; import { serializeDate } from '../../../helpers/query'; +import { withBranchLikes } from '../../../queries/branch'; import { BranchLike } from '../../../types/branch-like'; import { ComponentQualifier, isPortfolioLike, isProject } from '../../../types/component'; import { @@ -115,11 +110,9 @@ interface Props { branchLike?: BranchLike; component?: Component; currentUser: CurrentUser; - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => void; location: Location; router: Router; } - export interface State { bulkChangeModal: boolean; cannotShowOpenIssue?: boolean; @@ -153,7 +146,6 @@ export interface State { const DEFAULT_QUERY = { resolved: 'false' }; const MAX_INITAL_FETCH = 1000; -const BRANCH_STATUS_REFRESH_INTERVAL = 1000; const VARIANTS_FACET = 'codeVariants'; export class App extends React.PureComponent<Props, State> { @@ -197,8 +189,6 @@ export class App extends React.PureComponent<Props, State> { referencedUsers: {}, selected: getOpen(props.location.query), }; - - this.refreshBranchStatus = debounce(this.refreshBranchStatus, BRANCH_STATUS_REFRESH_INTERVAL); } static getDerivedStateFromProps(props: Props, state: State) { @@ -835,8 +825,6 @@ export class App extends React.PureComponent<Props, State> { }; handleIssueChange = (issue: Issue) => { - this.refreshBranchStatus(); - this.setState((state) => ({ issues: state.issues.map((candidate) => (candidate.key === issue.key ? issue : candidate)), })); @@ -856,7 +844,6 @@ export class App extends React.PureComponent<Props, State> { handleBulkChangeDone = () => { this.setState({ checkAll: false }); - this.refreshBranchStatus(); this.fetchFirstIssues(false).catch(() => undefined); this.handleCloseBulkChange(); }; @@ -910,14 +897,6 @@ export class App extends React.PureComponent<Props, State> { this.setState(actions.selectPreviousFlow); }; - refreshBranchStatus = () => { - const { branchLike, component } = this.props; - - if (branchLike && component && isPullRequest(branchLike)) { - this.props.fetchBranchStatus(branchLike, component.key); - } - }; - renderBulkChange() { const { currentUser } = this.props; const { checkAll, bulkChangeModal, checked, issues, paging } = this.state; @@ -1324,7 +1303,7 @@ export class App extends React.PureComponent<Props, State> { } export default withIndexationGuard( - withRouter(withCurrentUserContext(withBranchStatusActions(withComponentContext(App)))), + withRouter(withComponentContext(withCurrentUserContext(withBranchLikes(App)))), PageContext.Issues ); diff --git a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx index b964c4f1172..aec43a577d0 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx @@ -26,6 +26,7 @@ import CurrentUserContextProvider from '../../../../app/components/current-user/ import { SEVERITIES } from '../../../../helpers/constants'; import { mockIssue, mockLoggedInUser } from '../../../../helpers/testMocks'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { ComponentPropsType } from '../../../../helpers/testUtils'; import { IssueType } from '../../../../types/issues'; import { Issue } from '../../../../types/types'; import { CurrentUser } from '../../../../types/users'; @@ -187,7 +188,7 @@ it('should properly submit', async () => { function renderBulkChangeModal( issues: Issue[], - props: Partial<BulkChangeModal['props']> = {}, + props: Partial<ComponentPropsType<typeof BulkChangeModal>> = {}, currentUser: CurrentUser = mockLoggedInUser() ) { return renderComponent( diff --git a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx index 3962f6ba981..04b3969f7dd 100644 --- a/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx +++ b/server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx @@ -23,7 +23,7 @@ import * as React from 'react'; import { mockFlowLocation, mockIssue, mockPaging } from '../../../../helpers/testMocks'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; import { byRole, byText } from '../../../../helpers/testSelector'; -import { FCProps } from '../../../../helpers/testUtils'; +import { ComponentPropsType } from '../../../../helpers/testUtils'; import { FlowType, Issue } from '../../../../types/types'; import { VISIBLE_LOCATIONS_COLLAPSE } from '../IssueLocationsCrossFile'; import SubnavigationIssuesList from '../SubnavigationIssuesList'; @@ -245,7 +245,7 @@ function getPageObject() { function renderConciseIssues( issues: Issue[], - listProps: Partial<FCProps<typeof SubnavigationIssuesList>> = {} + listProps: Partial<ComponentPropsType<typeof SubnavigationIssuesList>> = {} ) { const wrapper = renderComponent( <SubnavigationIssuesList @@ -266,7 +266,7 @@ function renderConciseIssues( function override( issues: Issue[], - listProps: Partial<FCProps<typeof SubnavigationIssuesList>> = {} + listProps: Partial<ComponentPropsType<typeof SubnavigationIssuesList>> = {} ) { wrapper.rerender( <SubnavigationIssuesList diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx index 005897e31d2..8e06b68f8d9 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx @@ -166,7 +166,6 @@ export class SidebarClass extends React.PureComponent<Props> { const displayPeriodFilter = component !== undefined && !isPortfolioLike(component.qualifier); const displayProjectsFacet = !component || isView(component.qualifier); - const displayAuthorFacet = !component || component.qualifier !== ComponentQualifier.Developper; return ( <> @@ -356,7 +355,7 @@ export class SidebarClass extends React.PureComponent<Props> { </> )} - {displayAuthorFacet && !disableDeveloperAggregatedInfo && ( + {!disableDeveloperAggregatedInfo && ( <> <BasicSeparator className="sw-my-4" /> 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 020ae38d128..cef93cc97d2 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 @@ -97,6 +97,13 @@ export default class BranchOverview extends React.PureComponent<Props, State> { this.loadHistory(); } + componentDidUpdate(prevProps: Props) { + if (prevProps.branch !== this.props.branch) { + this.loadStatus(); + this.loadHistory(); + } + } + componentWillUnmount() { this.mounted = false; } 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 b3af0f38b15..96a4bcf287b 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 @@ -26,8 +26,8 @@ import withComponentContext from '../../../app/components/componentContext/withC 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 { BranchLike } from '../../../types/branch-like'; import { isPortfolioLike } from '../../../types/component'; import { Feature } from '../../../types/features'; import { Component } from '../../../types/types'; @@ -36,8 +36,6 @@ import PullRequestOverview from '../pullRequests/PullRequestOverview'; import EmptyOverview from './EmptyOverview'; interface AppProps extends WithAvailableFeaturesProps { - branchLike?: BranchLike; - branchLikes: BranchLike[]; component: Component; isInProgress?: boolean; isPending?: boolean; @@ -45,13 +43,16 @@ interface AppProps extends WithAvailableFeaturesProps { } export function App(props: AppProps) { - const { branchLike, branchLikes, component, projectBinding, isPending, isInProgress } = props; + const { component, projectBinding, isPending, isInProgress } = props; const branchSupportEnabled = props.hasFeature(Feature.BranchSupport); + const { data } = useBranchesQuery(component); - if (isPortfolioLike(component.qualifier)) { + if (isPortfolioLike(component.qualifier) || !data) { return null; } + const { branchLike, branchLikes } = data; + return ( <> <Helmet defer={false} title={translate('overview.page')} /> diff --git a/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx b/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx index 22395f7a113..eeaeaf0c1c5 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx +++ b/server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx @@ -19,14 +19,21 @@ */ import { screen } from '@testing-library/react'; import * as React from 'react'; +import BranchesServiceMock from '../../../../api/mocks/BranchesServiceMock'; import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider'; -import { mockBranch, mockMainBranch } from '../../../../helpers/mocks/branch-like'; +import { mockBranch } from '../../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../../helpers/mocks/component'; import { mockCurrentUser } from '../../../../helpers/testMocks'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; import { ComponentQualifier } from '../../../../types/component'; import { App } from '../App'; +const handler = new BranchesServiceMock(); + +beforeEach(() => { + handler.reset(); +}); + it('should render Empty Overview for Application with no analysis', async () => { renderApp({ component: mockComponent({ qualifier: ComponentQualifier.Application }) }); @@ -37,7 +44,7 @@ it('should render Empty Overview on main branch with no analysis', async () => { renderApp({}, mockCurrentUser()); expect( - await screen.findByText('provisioning.no_analysis_on_main_branch.master') + await screen.findByText('provisioning.no_analysis_on_main_branch.main') ).toBeInTheDocument(); }); @@ -46,7 +53,7 @@ it('should render Empty Overview on main branch with multiple branches with bad expect( await screen.findByText( - 'provisioning.no_analysis_on_main_branch.bad_configuration.master.branches.main_branch' + 'provisioning.no_analysis_on_main_branch.bad_configuration.main.branches.main_branch' ) ).toBeInTheDocument(); }); @@ -68,13 +75,8 @@ it('should not render for portfolios and subportfolios', () => { function renderApp(props = {}, userProps = {}) { return renderComponent( <CurrentUserContextProvider currentUser={mockCurrentUser({ isLoggedIn: true, ...userProps })}> - <App - hasFeature={jest.fn().mockReturnValue(false)} - branchLikes={[]} - branchLike={mockMainBranch()} - component={mockComponent()} - {...props} - /> - </CurrentUserContextProvider> + <App hasFeature={jest.fn().mockReturnValue(false)} component={mockComponent()} {...props} /> + </CurrentUserContextProvider>, + '/?id=my-project' ); } diff --git a/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx b/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx index 7bb2f167366..d5be3ce0291 100644 --- a/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx +++ b/server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx @@ -29,21 +29,20 @@ import { PageTitle, TextMuted, } from 'design-system'; -import { differenceBy, uniq } from 'lodash'; +import { uniq } from 'lodash'; import * as React from 'react'; +import { useEffect, useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { getMeasuresWithMetrics } from '../../../api/measures'; -import { BranchStatusContextInterface } from '../../../app/components/branch-status/BranchStatusContext'; -import withBranchStatus from '../../../app/components/branch-status/withBranchStatus'; -import withBranchStatusActions from '../../../app/components/branch-status/withBranchStatusActions'; import HelpTooltip from '../../../components/controls/HelpTooltip'; import { duplicationRatingConverter } from '../../../components/measure/utils'; import { getBranchLikeQuery } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; import { enhanceConditionWithMeasure, enhanceMeasuresWithMetrics } from '../../../helpers/measures'; import { isDefined } from '../../../helpers/types'; -import { getQualityGatesUrl, getQualityGateUrl } from '../../../helpers/urls'; -import { BranchStatusData, PullRequest } from '../../../types/branch-like'; +import { getQualityGateUrl, getQualityGatesUrl } from '../../../helpers/urls'; +import { useBranchStatusQuery } from '../../../queries/branch'; +import { PullRequest } from '../../../types/branch-like'; import { IssueType } from '../../../types/issues'; import { Component, MeasureEnhanced } from '../../../types/types'; import MeasuresPanelIssueMeasure from '../branches/MeasuresPanelIssueMeasure'; @@ -57,73 +56,21 @@ import SonarLintPromotion from '../components/SonarLintPromotion'; import '../styles.css'; import { MeasurementType, PR_METRICS } from '../utils'; -interface Props extends BranchStatusData, Pick<BranchStatusContextInterface, 'fetchBranchStatus'> { +interface Props { branchLike: PullRequest; component: Component; } -interface State { - loading: boolean; - measures: MeasureEnhanced[]; -} - -export class PullRequestOverview extends React.PureComponent<Props, State> { - mounted = false; - - state: State = { - loading: false, - measures: [], - }; +export default function PullRequestOverview(props: Props) { + const { component, branchLike } = props; + const [loadingMeasure, setLoadingMeasure] = useState(false); + const [measures, setMeasures] = useState<MeasureEnhanced[]>([]); + const { data: { conditions, ignoredConditions, status } = {}, isLoading } = + useBranchStatusQuery(component); + const loading = isLoading || loadingMeasure; - componentDidMount() { - this.mounted = true; - if (this.props.conditions === undefined) { - this.fetchBranchStatusData(); - } else { - this.fetchBranchData(); - } - } - - componentDidUpdate(prevProps: Props) { - if (this.conditionsHaveChanged(prevProps)) { - this.fetchBranchData(); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - conditionsHaveChanged = (prevProps: Props) => { - const prevConditions = prevProps.conditions ?? []; - const newConditions = this.props.conditions ?? []; - const diff = differenceBy( - prevConditions.filter((c) => c.level === 'ERROR'), - newConditions.filter((c) => c.level === 'ERROR'), - (c) => c.metric - ); - - return ( - (prevProps.conditions === undefined && this.props.conditions !== undefined) || diff.length > 0 - ); - }; - - fetchBranchStatusData = () => { - const { - branchLike, - component: { key }, - } = this.props; - this.props.fetchBranchStatus(branchLike, key); - }; - - fetchBranchData = () => { - const { - branchLike, - component: { key }, - conditions, - } = this.props; - - this.setState({ loading: true }); + useEffect(() => { + setLoadingMeasure(true); const metricKeys = conditions !== undefined @@ -131,153 +78,140 @@ export class PullRequestOverview extends React.PureComponent<Props, State> { uniq([...PR_METRICS, ...conditions.filter((c) => c.level !== 'OK').map((c) => c.metric)]) : PR_METRICS; - getMeasuresWithMetrics(key, metricKeys, getBranchLikeQuery(branchLike)).then( + getMeasuresWithMetrics(component.key, metricKeys, getBranchLikeQuery(branchLike)).then( ({ component, metrics }) => { - if (this.mounted && component.measures) { - this.setState({ - loading: false, - measures: enhanceMeasuresWithMetrics(component.measures || [], metrics), - }); + if (component.measures) { + setLoadingMeasure(false); + setMeasures(enhanceMeasuresWithMetrics(component.measures || [], metrics)); } }, () => { - if (this.mounted) { - this.setState({ loading: false }); - } + setLoadingMeasure(false); } ); - }; + }, [branchLike, component.key, conditions]); + + if (loading) { + return ( + <LargeCenteredLayout> + <div className="sw-p-6"> + <DeferredSpinner loading /> + </div> + </LargeCenteredLayout> + ); + } - render() { - const { branchLike, component, conditions, ignoredConditions, status } = this.props; - const { loading, measures } = this.state; + if (conditions === undefined) { + return null; + } - if (loading) { - return ( - <LargeCenteredLayout> - <div className="sw-p-6"> - <DeferredSpinner loading /> - </div> - </LargeCenteredLayout> - ); - } + const path = + component.qualityGate === undefined + ? getQualityGatesUrl() + : getQualityGateUrl(component.qualityGate.name); + + const failedConditions = conditions + .filter((condition) => condition.level === 'ERROR') + .map((c) => enhanceConditionWithMeasure(c, measures)) + .filter(isDefined); + + return ( + <LargeCenteredLayout> + <div className="it__pr-overview sw-mt-12"> + <div className="sw-flex"> + <div className="sw-flex sw-flex-col sw-mr-12 width-30"> + <QualityGateStatusTitle /> + <Card> + {status && ( + <QualityGateStatusHeader + status={status} + failedConditionCount={failedConditions.length} + /> + )} + + <div className="sw-flex sw-items-center sw-mb-4"> + <TextMuted text={translate('overview.on_new_code_long')} /> + <HelpTooltip + className="sw-ml-2" + overlay={ + <FormattedMessage + defaultMessage={translate('overview.quality_gate.conditions_on_new_code')} + id="overview.quality_gate.conditions_on_new_code" + values={{ + link: <Link to={path}>{translate('overview.quality_gate')}</Link>, + }} + /> + } + > + <HelperHintIcon aria-label="help-tooltip" /> + </HelpTooltip> + </div> - if (conditions === undefined) { - return null; - } + {ignoredConditions && <IgnoredConditionWarning />} - const path = - component.qualityGate === undefined - ? getQualityGatesUrl() - : getQualityGateUrl(component.qualityGate.name); + {status === 'OK' && failedConditions.length === 0 && <QualityGateStatusPassedView />} - const failedConditions = conditions - .filter((condition) => condition.level === 'ERROR') - .map((c) => enhanceConditionWithMeasure(c, measures)) - .filter(isDefined); + {status !== 'OK' && <BasicSeparator />} - return ( - <LargeCenteredLayout> - <div className="it__pr-overview sw-mt-12"> - <div className="sw-flex"> - <div className="sw-flex sw-flex-col sw-mr-12 width-30"> - <QualityGateStatusTitle /> - <Card> - {status && ( - <QualityGateStatusHeader - status={status} - failedConditionCount={failedConditions.length} + {failedConditions.length > 0 && ( + <div> + <QualityGateConditions + branchLike={branchLike} + collapsible + component={component} + failedConditions={failedConditions} /> - )} - - <div className="sw-flex sw-items-center sw-mb-4"> - <TextMuted text={translate('overview.on_new_code_long')} /> - <HelpTooltip - className="sw-ml-2" - overlay={ - <FormattedMessage - defaultMessage={translate('overview.quality_gate.conditions_on_new_code')} - id="overview.quality_gate.conditions_on_new_code" - values={{ - link: <Link to={path}>{translate('overview.quality_gate')}</Link>, - }} - /> - } - > - <HelperHintIcon aria-label="help-tooltip" /> - </HelpTooltip> </div> + )} + </Card> + <SonarLintPromotion qgConditions={conditions} /> + </div> - {ignoredConditions && <IgnoredConditionWarning />} - - {status === 'OK' && failedConditions.length === 0 && ( - <QualityGateStatusPassedView /> - )} - - {status !== 'OK' && <BasicSeparator />} - - {failedConditions.length > 0 && ( - <div> - <QualityGateConditions - branchLike={branchLike} - collapsible - component={component} - failedConditions={failedConditions} - /> - </div> - )} - </Card> - <SonarLintPromotion qgConditions={conditions} /> + <div className="sw-flex-1"> + <div className="sw-body-md-highlight"> + <PageTitle as="h2" text={translate('overview.measures')} /> </div> - <div className="sw-flex-1"> - <div className="sw-body-md-highlight"> - <PageTitle as="h2" text={translate('overview.measures')} /> - </div> + <div className="sw-grid sw-grid-cols-2 sw-gap-4 sw-mt-4"> + {[ + IssueType.Bug, + IssueType.Vulnerability, + IssueType.SecurityHotspot, + IssueType.CodeSmell, + ].map((type: IssueType) => ( + <Card key={type} className="sw-p-8"> + <MeasuresPanelIssueMeasure + branchLike={branchLike} + component={component} + isNewCodeTab + measures={measures} + type={type} + /> + </Card> + ))} - <div className="sw-grid sw-grid-cols-2 sw-gap-4 sw-mt-4"> - {[ - IssueType.Bug, - IssueType.Vulnerability, - IssueType.SecurityHotspot, - IssueType.CodeSmell, - ].map((type: IssueType) => ( + {[MeasurementType.Coverage, MeasurementType.Duplication].map( + (type: MeasurementType) => ( <Card key={type} className="sw-p-8"> - <MeasuresPanelIssueMeasure + <MeasuresPanelPercentMeasure branchLike={branchLike} component={component} - isNewCodeTab measures={measures} + ratingIcon={renderMeasureIcon(type)} type={type} + useDiffMetric /> </Card> - ))} - - {[MeasurementType.Coverage, MeasurementType.Duplication].map( - (type: MeasurementType) => ( - <Card key={type} className="sw-p-8"> - <MeasuresPanelPercentMeasure - branchLike={branchLike} - component={component} - measures={measures} - ratingIcon={renderMeasureIcon(type)} - type={type} - useDiffMetric - /> - </Card> - ) - )} - </div> + ) + )} </div> </div> </div> - </LargeCenteredLayout> - ); - } + </div> + </LargeCenteredLayout> + ); } -export default withBranchStatus(withBranchStatusActions(PullRequestOverview)); - function renderMeasureIcon(type: MeasurementType) { if (type === MeasurementType.Coverage) { return function CoverageIndicatorRenderer(value?: string) { diff --git a/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx b/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx index aba05081a7f..53473aeb27c 100644 --- a/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx +++ b/server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx @@ -17,17 +17,23 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import * as React from 'react'; +import { getQualityGateProjectStatus } from '../../../../api/quality-gates'; import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider'; import { mockPullRequest } from '../../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../../helpers/mocks/component'; -import { mockQualityGateStatusCondition } from '../../../../helpers/mocks/quality-gates'; +import { + mockQualityGateProjectCondition, + mockQualityGateStatusCondition, +} from '../../../../helpers/mocks/quality-gates'; import { mockLoggedInUser, mockMetric, mockPeriod } from '../../../../helpers/testMocks'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; +import { ComponentPropsType } from '../../../../helpers/testUtils'; import { ComponentQualifier } from '../../../../types/component'; import { MetricKey } from '../../../../types/metrics'; -import { PullRequestOverview } from '../PullRequestOverview'; +import { CaycStatus } from '../../../../types/types'; +import PullRequestOverview from '../PullRequestOverview'; jest.mock('../../../../api/measures', () => { return { @@ -112,40 +118,59 @@ jest.mock('../../../../api/quality-gates', () => { }); it('should render correctly for a passed QG', async () => { - renderPullRequestOverview({ status: 'OK', conditions: [] }); + jest.mocked(getQualityGateProjectStatus).mockResolvedValueOnce({ + status: 'OK', + conditions: [], + caycStatus: CaycStatus.Compliant, + ignoredConditions: false, + }); + renderPullRequestOverview(); - expect(await screen.findByText('metric.level.OK')).toBeInTheDocument(); + await waitFor(async () => expect(await screen.findByText('metric.level.OK')).toBeInTheDocument()); }); it('should render correctly if conditions are ignored', async () => { - renderPullRequestOverview({ conditions: [], ignoredConditions: true }); + jest.mocked(getQualityGateProjectStatus).mockResolvedValueOnce({ + status: 'OK', + conditions: [], + caycStatus: CaycStatus.Compliant, + ignoredConditions: true, + }); + renderPullRequestOverview(); - expect(await screen.findByText('overview.quality_gate.ignored_conditions')).toBeInTheDocument(); + await waitFor(async () => + expect(await screen.findByText('overview.quality_gate.ignored_conditions')).toBeInTheDocument() + ); }); it('should render correctly for a failed QG', async () => { - renderPullRequestOverview({ + jest.mocked(getQualityGateProjectStatus).mockResolvedValueOnce({ status: 'ERROR', conditions: [ - mockQualityGateStatusCondition({ - error: '2.0', - metric: MetricKey.new_coverage, - period: 1, + mockQualityGateProjectCondition({ + errorThreshold: '2.0', + metricKey: MetricKey.new_coverage, + periodIndex: 1, }), - mockQualityGateStatusCondition({ - error: '1.0', - metric: MetricKey.duplicated_lines, - period: 1, + mockQualityGateProjectCondition({ + errorThreshold: '1.0', + metricKey: MetricKey.duplicated_lines, + periodIndex: 1, }), - mockQualityGateStatusCondition({ - error: '3', - metric: MetricKey.new_bugs, - period: 1, + mockQualityGateProjectCondition({ + errorThreshold: '3', + metricKey: MetricKey.new_bugs, + periodIndex: 1, }), ], + caycStatus: CaycStatus.Compliant, + ignoredConditions: true, }); + renderPullRequestOverview(); - expect(await screen.findByText('metric.level.ERROR')).toBeInTheDocument(); + await waitFor(async () => + expect(await screen.findByText('metric.level.ERROR')).toBeInTheDocument() + ); expect(await screen.findByText('metric.new_coverage.name')).toBeInTheDocument(); expect(await screen.findByText('quality_gates.operator.GT 2.0%')).toBeInTheDocument(); @@ -158,11 +183,12 @@ it('should render correctly for a failed QG', async () => { expect(screen.getByText('quality_gates.operator.GT 3')).toBeInTheDocument(); }); -function renderPullRequestOverview(props: Partial<PullRequestOverview['props']> = {}) { +function renderPullRequestOverview( + props: Partial<ComponentPropsType<typeof PullRequestOverview>> = {} +) { renderComponent( <CurrentUserContextProvider currentUser={mockLoggedInUser()}> <PullRequestOverview - fetchBranchStatus={jest.fn()} branchLike={mockPullRequest()} component={mockComponent({ breadcrumbs: [mockComponent({ key: 'foo' })], diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchList.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchList.tsx index f8e5ab4c6ac..857895cb8dd 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchList.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/BranchList.tsx @@ -54,6 +54,12 @@ export default class BranchList extends React.PureComponent<Props, State> { this.fetchBranches(); } + componentDidUpdate(prevProps: Props) { + if (prevProps.branchList !== this.props.branchList) { + this.fetchBranches(); + } + } + componentWillUnmount() { this.mounted = false; } diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineApp.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineApp.tsx index f28d2d251bd..3d73558be4d 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineApp.tsx @@ -36,6 +36,7 @@ import { DEFAULT_NEW_CODE_DEFINITION_TYPE, getNumberOfDaysDefaultValue, } from '../../../helpers/new-code-definition'; +import { withBranchLikes } from '../../../queries/branch'; import { AppState } from '../../../types/appstate'; import { Branch, BranchLike } from '../../../types/branch-like'; import { Feature } from '../../../types/features'; @@ -130,7 +131,7 @@ class ProjectBaselineApp extends React.PureComponent<Props, State> { sortAndFilterBranches(branchLikes: BranchLike[] = []) { const branchList = sortBranches(branchLikes.filter(isBranch)); - this.setState({ branchList, referenceBranch: branchList[0].name }); + this.setState({ branchList, referenceBranch: branchList[0]?.name }); } fetchLeakPeriodSetting() { @@ -141,7 +142,7 @@ class ProjectBaselineApp extends React.PureComponent<Props, State> { Promise.all([ getNewCodePeriod(), getNewCodePeriod({ - branch: this.props.hasFeature(Feature.BranchSupport) ? undefined : branchLike.name, + branch: this.props.hasFeature(Feature.BranchSupport) ? undefined : branchLike?.name, project: component.key, }), ]).then( @@ -344,4 +345,6 @@ class ProjectBaselineApp extends React.PureComponent<Props, State> { } } -export default withComponentContext(withAvailableFeatures(withAppStateContext(ProjectBaselineApp))); +export default withComponentContext( + withAvailableFeatures(withAppStateContext(withBranchLikes(ProjectBaselineApp))) +); diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx index e16920aae9c..deb5da7ea24 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx @@ -40,7 +40,7 @@ import BranchAnalysisList from './BranchAnalysisList'; export interface ProjectBaselineSelectorProps { analysis?: string; - branch: Branch; + branch?: Branch; branchList: Branch[]; branchesEnabled?: boolean; canAdmin: boolean | undefined; @@ -94,6 +94,10 @@ export default function ProjectBaselineSelector(props: ProjectBaselineSelectorPr selected, }); + if (branch === undefined) { + return null; + } + return ( <form className="project-baseline-selector" onSubmit={props.onSubmit}> <div className="big-spacer-top spacer-bottom" role="radiogroup"> diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx index d1fc4e010eb..6a425ddc54c 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx @@ -21,9 +21,9 @@ import { within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { first, last } from 'lodash'; import selectEvent from 'react-select-event'; +import BranchesServiceMock from '../../../../api/mocks/BranchesServiceMock'; import NewCodePeriodsServiceMock from '../../../../api/mocks/NewCodePeriodsServiceMock'; import { ProjectActivityServiceMock } from '../../../../api/mocks/ProjectActivityServiceMock'; -import { mockBranch } from '../../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../../helpers/mocks/component'; import { mockNewCodePeriodBranch } from '../../../../helpers/mocks/new-code-definition'; import { mockAppState } from '../../../../helpers/testMocks'; @@ -38,11 +38,14 @@ import routes from '../../routes'; jest.mock('../../../../api/newCodePeriod'); jest.mock('../../../../api/projectActivity'); +jest.mock('../../../../api/branches'); const codePeriodsMock = new NewCodePeriodsServiceMock(); const projectActivityMock = new ProjectActivityServiceMock(); +const branchHandler = new BranchesServiceMock(); afterEach(() => { + branchHandler.reset(); codePeriodsMock.reset(); projectActivityMock.reset(); }); @@ -52,7 +55,7 @@ it('renders correctly without branch support feature', async () => { renderProjectBaselineApp(); await ui.appIsLoaded(); - expect(ui.generalSettingRadio.get()).toBeChecked(); + expect(await ui.generalSettingRadio.find()).toBeChecked(); expect(ui.specificAnalysisRadio.query()).not.toBeInTheDocument(); // User is not admin @@ -74,7 +77,7 @@ it('prevents selection of global setting if it is not compliant and warns non-ad renderProjectBaselineApp(); await ui.appIsLoaded(); - expect(ui.generalSettingRadio.get()).toBeChecked(); + expect(await ui.generalSettingRadio.find()).toBeChecked(); expect(ui.generalSettingRadio.get()).toBeDisabled(); expect(ui.complianceWarning.get()).toBeVisible(); }); @@ -90,7 +93,7 @@ it('prevents selection of global setting if it is not compliant and warns admin renderProjectBaselineApp({ appState: mockAppState({ canAdmin: true }) }); await ui.appIsLoaded(); - expect(ui.generalSettingRadio.get()).toBeChecked(); + expect(await ui.generalSettingRadio.find()).toBeChecked(); expect(ui.generalSettingRadio.get()).toBeDisabled(); expect(ui.complianceWarningAdmin.get()).toBeVisible(); expect(ui.complianceWarning.query()).not.toBeInTheDocument(); @@ -104,7 +107,7 @@ it('renders correctly with branch support feature', async () => { }); await ui.appIsLoaded(); - expect(ui.generalSettingRadio.get()).toBeChecked(); + expect(await ui.generalSettingRadio.find()).toBeChecked(); expect(ui.specificAnalysisRadio.query()).not.toBeInTheDocument(); // User is admin @@ -120,7 +123,7 @@ it('can set previous version specific setting', async () => { renderProjectBaselineApp(); await ui.appIsLoaded(); - expect(ui.previousVersionRadio.get()).toHaveClass('disabled'); + expect(await ui.previousVersionRadio.find()).toHaveClass('disabled'); await ui.setPreviousVersionSetting(); expect(ui.previousVersionRadio.get()).toBeChecked(); @@ -141,7 +144,7 @@ it('can set number of days specific setting', async () => { renderProjectBaselineApp(); await ui.appIsLoaded(); - expect(ui.numberDaysRadio.get()).toHaveClass('disabled'); + expect(await ui.numberDaysRadio.find()).toHaveClass('disabled'); await ui.setNumberDaysSetting('10'); expect(ui.numberDaysRadio.get()).toBeChecked(); @@ -164,7 +167,7 @@ it('can set reference branch specific setting', async () => { }); await ui.appIsLoaded(); - expect(ui.referenceBranchRadio.get()).toHaveClass('disabled'); + expect(await ui.referenceBranchRadio.find()).toHaveClass('disabled'); await ui.setReferenceBranchSetting('main'); expect(ui.referenceBranchRadio.get()).toBeChecked(); @@ -183,7 +186,7 @@ it('cannot set specific analysis setting', async () => { renderProjectBaselineApp(); await ui.appIsLoaded(); - expect(ui.specificAnalysisRadio.get()).toBeChecked(); + expect(await ui.specificAnalysisRadio.find()).toBeChecked(); expect(ui.specificAnalysisRadio.get()).toHaveClass('disabled'); expect(ui.specificAnalysisWarning.get()).toBeInTheDocument(); @@ -274,18 +277,25 @@ it('can set a reference branch setting for branch', async () => { }); await ui.appIsLoaded(); - await ui.setBranchReferenceToBranchSetting('main', 'feature'); + await ui.setBranchReferenceToBranchSetting('main', 'normal-branch'); - expect(byRole('table').byText('baseline.reference_branch: feature').get()).toBeInTheDocument(); + expect( + byRole('table').byText('baseline.reference_branch: normal-branch').get() + ).toBeInTheDocument(); }); -function renderProjectBaselineApp(context: RenderContext = {}) { - const branch = mockBranch({ name: 'main', isMain: true }); - return renderAppWithComponentContext('baseline', routes, context, { - component: mockComponent(), - branchLike: branch, - branchLikes: [branch, mockBranch({ name: 'feature' })], - }); +function renderProjectBaselineApp(context: RenderContext = {}, params?: string) { + return renderAppWithComponentContext( + 'baseline', + routes, + { + ...context, + navigateTo: params ? `baseline?id=my-project&${params}` : 'baseline?id=my-project', + }, + { + component: mockComponent(), + } + ); } function getPageObjects() { @@ -293,6 +303,7 @@ function getPageObjects() { const ui = { pageHeading: byRole('heading', { name: 'project_baseline.page' }), + branchTableHeading: byText('branch_list.branch'), branchListHeading: byRole('heading', { name: 'project_baseline.default_setting' }), generalSettingsLink: byRole('link', { name: 'project_baseline.page.description2.link' }), generalSettingRadio: byRole('radio', { name: 'project_baseline.global_setting' }), diff --git a/server/sonar-web/src/main/js/apps/projectBranches/ProjectBranchesApp.tsx b/server/sonar-web/src/main/js/apps/projectBranches/ProjectBranchesApp.tsx index 48407906d2f..c52f677190e 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/ProjectBranchesApp.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/ProjectBranchesApp.tsx @@ -21,19 +21,16 @@ import * as React from 'react'; import { Helmet } from 'react-helmet-async'; import withComponentContext from '../../app/components/componentContext/withComponentContext'; import { translate } from '../../helpers/l10n'; -import { BranchLike } from '../../types/branch-like'; import { Component } from '../../types/types'; import BranchLikeTabs from './components/BranchLikeTabs'; import LifetimeInformation from './components/LifetimeInformation'; export interface ProjectBranchesAppProps { - branchLikes: BranchLike[]; component: Component; - onBranchesChange: () => void; } function ProjectBranchesApp(props: ProjectBranchesAppProps) { - const { branchLikes, component } = props; + const { component } = props; return ( <div className="page page-limited" id="project-branch-like"> @@ -43,11 +40,7 @@ function ProjectBranchesApp(props: ProjectBranchesAppProps) { <LifetimeInformation /> </header> - <BranchLikeTabs - branchLikes={branchLikes} - component={component} - onBranchesChange={props.onBranchesChange} - /> + <BranchLikeTabs component={component} /> </div> ); } diff --git a/server/sonar-web/src/main/js/apps/projectBranches/__tests__/ProjectBranchesApp-it.tsx b/server/sonar-web/src/main/js/apps/projectBranches/__tests__/ProjectBranchesApp-it.tsx index 5b3a1650df1..08a57185893 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/__tests__/ProjectBranchesApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/__tests__/ProjectBranchesApp-it.tsx @@ -20,56 +20,61 @@ import { act, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; -import BranchStatusContextProvider from '../../../app/components/branch-status/BranchStatusContextProvider'; -import withBranchStatusActions from '../../../app/components/branch-status/withBranchStatusActions'; import { ComponentContext } from '../../../app/components/componentContext/ComponentContext'; import { mockComponent } from '../../../helpers/mocks/component'; import { mockAppState } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; import { byRole } from '../../../helpers/testSelector'; import { AppState } from '../../../types/appstate'; -import { BranchLike } from '../../../types/branch-like'; +import { Feature } from '../../../types/features'; import { SettingsKey } from '../../../types/settings'; import ProjectBranchesApp from '../ProjectBranchesApp'; const handler = new BranchesServiceMock(); const settingsHandler = new SettingsServiceMock(); -const ui = { - branchTabContent: byRole('tabpanel', { name: 'project_branch_pull_request.tabs.branches' }), - branchTabBtn: byRole('tab', { name: 'project_branch_pull_request.tabs.branches' }), - linkForAdmin: byRole('link', { name: 'settings.page' }), - renameBranchBtn: byRole('button', { name: 'project_branch_pull_request.branch.rename' }), - deleteBranchBtn: byRole('button', { name: 'project_branch_pull_request.branch.delete' }), - deletePullRequestBtn: byRole('button', { +const ui = new (class UI { + branchTabContent = byRole('tabpanel', { name: 'project_branch_pull_request.tabs.branches' }); + branchTabBtn = byRole('tab', { name: 'project_branch_pull_request.tabs.branches' }); + linkForAdmin = byRole('link', { name: 'settings.page' }); + renameBranchBtn = byRole('button', { name: 'project_branch_pull_request.branch.rename' }); + deleteBranchBtn = byRole('button', { name: 'project_branch_pull_request.branch.delete' }); + deletePullRequestBtn = byRole('button', { name: 'project_branch_pull_request.pull_request.delete', - }), - pullRequestTabContent: byRole('tabpanel', { + }); + + pullRequestTabContent = byRole('tabpanel', { name: 'project_branch_pull_request.tabs.pull_requests', - }), - pullRequestTabBtn: byRole('tab', { + }); + + pullRequestTabBtn = byRole('tab', { name: 'project_branch_pull_request.tabs.pull_requests', - }), - renameBranchDialog: byRole('dialog', { name: 'project_branch_pull_request.branch.rename' }), - deleteBranchDialog: byRole('dialog', { name: 'project_branch_pull_request.branch.delete' }), - deletePullRequestDialog: byRole('dialog', { + }); + + renameBranchDialog = byRole('dialog', { name: 'project_branch_pull_request.branch.rename' }); + deleteBranchDialog = byRole('dialog', { name: 'project_branch_pull_request.branch.delete' }); + deletePullRequestDialog = byRole('dialog', { name: 'project_branch_pull_request.pull_request.delete', - }), - updateMasterBtn: byRole('button', { - name: 'project_branch_pull_request.branch.actions_label.master', - }), - updateSecondBranchBtn: byRole('button', { + }); + + updateMasterBtn = byRole('button', { + name: 'project_branch_pull_request.branch.actions_label.main', + }); + + updateSecondBranchBtn = byRole('button', { name: 'project_branch_pull_request.branch.actions_label.delete-branch', - }), - updateFirstPRBtn: byRole('button', { + }); + + updateFirstPRBtn = byRole('button', { name: 'project_branch_pull_request.branch.actions_label.01 – TEST-191 update master', - }), - getBranchRow: () => within(ui.branchTabContent.get()).getAllByRole('row'), - getPullRequestRow: () => within(ui.pullRequestTabContent.get()).getAllByRole('row'), -}; + }); + + branchRow = this.branchTabContent.byRole('row'); + pullRequestRow = this.pullRequestTabContent.byRole('row'); +})(); beforeEach(() => { jest.useFakeTimers({ @@ -90,13 +95,13 @@ it('should show all branches', async () => { expect(await ui.branchTabContent.find()).toBeInTheDocument(); expect(ui.pullRequestTabContent.query()).not.toBeInTheDocument(); expect(ui.linkForAdmin.query()).not.toBeInTheDocument(); - expect(ui.getBranchRow()).toHaveLength(4); - expect(ui.getBranchRow()[1]).toHaveTextContent('masterbranches.main_branchOK1 month ago'); - expect(within(ui.getBranchRow()[1]).getByRole('switch')).toBeDisabled(); - expect(within(ui.getBranchRow()[1]).getByRole('switch')).toBeChecked(); - expect(ui.getBranchRow()[2]).toHaveTextContent('delete-branchERROR2 days ago'); - expect(within(ui.getBranchRow()[2]).getByRole('switch')).toBeEnabled(); - expect(within(ui.getBranchRow()[2]).getByRole('switch')).not.toBeChecked(); + expect(await ui.branchRow.findAll()).toHaveLength(4); + expect(ui.branchRow.getAt(1)).toHaveTextContent('mainbranches.main_branchOK1 month ago'); + expect(within(ui.branchRow.getAt(1)).getByRole('switch')).toBeDisabled(); + expect(within(ui.branchRow.getAt(1)).getByRole('switch')).toBeChecked(); + expect(ui.branchRow.getAt(2)).toHaveTextContent('delete-branchERROR2 days ago'); + expect(within(ui.branchRow.getAt(2)).getByRole('switch')).toBeEnabled(); + expect(within(ui.branchRow.getAt(2)).getByRole('switch')).not.toBeChecked(); }); it('should show link to change purge options for admin', async () => { @@ -112,7 +117,7 @@ it('should be able to rename main branch, but not others', async () => { expect(ui.renameBranchBtn.get()).toBeInTheDocument(); await user.click(ui.renameBranchBtn.get()); expect(ui.renameBranchDialog.get()).toBeInTheDocument(); - expect(within(ui.renameBranchDialog.get()).getByRole('textbox')).toHaveValue('master'); + expect(within(ui.renameBranchDialog.get()).getByRole('textbox')).toHaveValue('main'); expect( within(ui.renameBranchDialog.get()).getByRole('button', { name: 'rename' }) ).toBeDisabled(); @@ -120,12 +125,12 @@ it('should be able to rename main branch, but not others', async () => { expect( within(ui.renameBranchDialog.get()).getByRole('button', { name: 'rename' }) ).toBeDisabled(); - await user.type(within(ui.renameBranchDialog.get()).getByRole('textbox'), 'main'); + await user.type(within(ui.renameBranchDialog.get()).getByRole('textbox'), 'master'); expect(within(ui.renameBranchDialog.get()).getByRole('button', { name: 'rename' })).toBeEnabled(); await act(() => user.click(within(ui.renameBranchDialog.get()).getByRole('button', { name: 'rename' })) ); - expect(ui.getBranchRow()[1]).toHaveTextContent('mainbranches.main_branchOK1 month ago'); + expect(ui.branchRow.getAt(1)).toHaveTextContent('masterbranches.main_branchOK1 month ago'); await user.click(await ui.updateSecondBranchBtn.find()); expect(ui.renameBranchBtn.query()).not.toBeInTheDocument(); @@ -142,7 +147,7 @@ it('should be able to delete branch, but not main', async () => { await act(() => user.click(within(ui.deleteBranchDialog.get()).getByRole('button', { name: 'delete' })) ); - expect(ui.getBranchRow()).toHaveLength(3); + expect(ui.branchRow.getAll()).toHaveLength(3); await user.click(await ui.updateMasterBtn.find()); expect(ui.deleteBranchBtn.query()).not.toBeInTheDocument(); @@ -152,18 +157,18 @@ it('should exclude from purge', async () => { const user = userEvent.setup(); renderProjectBranchesApp(); expect(await ui.branchTabContent.find()).toBeInTheDocument(); - expect(within(ui.getBranchRow()[2]).getByRole('switch')).not.toBeChecked(); - await act(() => user.click(within(ui.getBranchRow()[2]).getByRole('switch'))); - expect(within(ui.getBranchRow()[2]).getByRole('switch')).toBeChecked(); + expect(within(await ui.branchRow.findAt(2)).getByRole('switch')).not.toBeChecked(); + await act(() => user.click(within(ui.branchRow.getAt(2)).getByRole('switch'))); + expect(within(ui.branchRow.getAt(2)).getByRole('switch')).toBeChecked(); - expect(within(ui.getBranchRow()[3]).getByRole('switch')).toBeChecked(); - await act(() => user.click(within(ui.getBranchRow()[3]).getByRole('switch'))); - expect(within(ui.getBranchRow()[3]).getByRole('switch')).not.toBeChecked(); + expect(within(ui.branchRow.getAt(3)).getByRole('switch')).toBeChecked(); + await act(() => user.click(within(ui.branchRow.getAt(3)).getByRole('switch'))); + expect(within(ui.branchRow.getAt(3)).getByRole('switch')).not.toBeChecked(); await user.click(ui.pullRequestTabBtn.get()); await user.click(ui.branchTabBtn.get()); - expect(within(ui.getBranchRow()[2]).getByRole('switch')).toBeChecked(); - expect(within(ui.getBranchRow()[3]).getByRole('switch')).not.toBeChecked(); + expect(within(ui.branchRow.getAt(2)).getByRole('switch')).toBeChecked(); + expect(within(ui.branchRow.getAt(3)).getByRole('switch')).not.toBeChecked(); }); it('should show all pull requests', async () => { @@ -172,9 +177,9 @@ it('should show all pull requests', async () => { await user.click(await ui.pullRequestTabBtn.find()); expect(await ui.pullRequestTabContent.find()).toBeInTheDocument(); expect(ui.branchTabContent.query()).not.toBeInTheDocument(); - expect(ui.getPullRequestRow()).toHaveLength(4); - expect(ui.getPullRequestRow()[1]).toHaveTextContent('01 – TEST-191 update masterOK1 month ago'); - expect(ui.getPullRequestRow()[2]).toHaveTextContent( + expect(await ui.pullRequestRow.findAll()).toHaveLength(4); + expect(ui.pullRequestRow.getAt(1)).toHaveTextContent('01 – TEST-191 update masterOK1 month ago'); + expect(ui.pullRequestRow.getAt(2)).toHaveTextContent( '02 – TEST-192 update normal-branchERROR2 days ago' ); }); @@ -183,7 +188,7 @@ it('should delete pull requests', async () => { const user = userEvent.setup(); renderProjectBranchesApp(); await user.click(await ui.pullRequestTabBtn.find()); - expect(ui.getPullRequestRow()).toHaveLength(4); + expect(await ui.pullRequestRow.findAll()).toHaveLength(4); await user.click(ui.updateFirstPRBtn.get()); await user.click(ui.deletePullRequestBtn.get()); expect(await ui.deletePullRequestDialog.find()).toBeInTheDocument(); @@ -191,57 +196,20 @@ it('should delete pull requests', async () => { await act(() => user.click(within(ui.deletePullRequestDialog.get()).getByRole('button', { name: 'delete' })) ); - expect(ui.getPullRequestRow()).toHaveLength(3); + expect(ui.pullRequestRow.getAll()).toHaveLength(3); }); function renderProjectBranchesApp(overrides?: Partial<AppState>) { - function TestWrapper(props: any) { - const [init, setInit] = useState<boolean>(false); - const [branches, setBranches] = useState<BranchLike[]>([ - ...handler.branches, - ...handler.pullRequests, - ]); - - const updateBranches = (branches: BranchLike[]) => { - branches.forEach((item) => { - props.updateBranchStatus(item, 'my-project', item.status?.qualityGateStatus); - }); - }; - - useEffect(() => { - updateBranches(branches); - setInit(true); - }, []); - - const onBranchesChange = () => { - const changedBranches = [...handler.branches, ...handler.pullRequests]; - updateBranches(changedBranches); - setBranches(changedBranches); - }; - - return init ? ( - <ComponentContext.Provider - value={{ - branchLikes: branches, - onBranchesChange, - onComponentChange: jest.fn(), - component: mockComponent(), - }} - > - {props.children} - </ComponentContext.Provider> - ) : null; - } - - const Wrapper = withBranchStatusActions(TestWrapper); - return renderComponent( - <BranchStatusContextProvider> - <Wrapper> - <ProjectBranchesApp /> - </Wrapper> - </BranchStatusContextProvider>, - '/', - { appState: mockAppState(overrides) } + <ComponentContext.Provider + value={{ + onComponentChange: jest.fn(), + component: mockComponent(), + }} + > + <ProjectBranchesApp /> + </ComponentContext.Provider>, + '/?id=my-project', + { appState: mockAppState(overrides), featureList: [Feature.BranchSupport] } ); } diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeRow.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeRow.tsx index 35d510b0e69..4f6c91183f5 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeRow.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeRow.tsx @@ -39,7 +39,6 @@ export interface BranchLikeRowProps { displayPurgeSetting?: boolean; onDelete: () => void; onRename: () => void; - onUpdatePurgeSetting: () => void; } function BranchLikeRow(props: BranchLikeRowProps) { @@ -58,16 +57,12 @@ function BranchLikeRow(props: BranchLikeRowProps) { </span> </td> <td className="nowrap"> - <BranchStatus branchLike={branchLike} component={component} /> + <BranchStatus branchLike={branchLike} /> </td> <td className="nowrap">{<DateFromNow date={branchLike.analysisDate} />}</td> {displayPurgeSetting && isBranch(branchLike) && ( <td className="nowrap js-test-purge-toggle-container"> - <BranchPurgeSetting - branch={branchLike} - component={component} - onUpdatePurgeSetting={props.onUpdatePurgeSetting} - /> + <BranchPurgeSetting branch={branchLike} component={component} /> </td> )} <td className="nowrap"> diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTable.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTable.tsx index 6cde13e2517..159b898de03 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTable.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTable.tsx @@ -31,7 +31,6 @@ export interface BranchLikeTableProps { displayPurgeSetting?: boolean; onDelete: (branchLike: BranchLike) => void; onRename: (branchLike: BranchLike) => void; - onUpdatePurgeSetting: () => void; title: string; } @@ -81,7 +80,6 @@ function BranchLikeTable(props: BranchLikeTableProps) { key={getBranchLikeKey(branchLike)} onDelete={() => props.onDelete(branchLike)} onRename={() => props.onRename(branchLike)} - onUpdatePurgeSetting={props.onUpdatePurgeSetting} /> ))} </tbody> diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTabs.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTabs.tsx index 1bd2f6705f0..9514fdd7373 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTabs.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTabs.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { useState } from 'react'; import BoxedTabs, { getTabId, getTabPanelId } from '../../../components/controls/BoxedTabs'; import BranchIcon from '../../../components/icons/BranchIcon'; import PullRequestIcon from '../../../components/icons/PullRequestIcon'; @@ -29,22 +30,15 @@ import { sortPullRequests, } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; -import { BranchLike } from '../../../types/branch-like'; +import { useBranchesQuery } from '../../../queries/branch'; +import { Branch, BranchLike, PullRequest } from '../../../types/branch-like'; import { Component } from '../../../types/types'; import BranchLikeTable from './BranchLikeTable'; import DeleteBranchModal from './DeleteBranchModal'; import RenameBranchModal from './RenameBranchModal'; interface Props { - branchLikes: BranchLike[]; component: Component; - onBranchesChange: () => void; -} - -interface State { - currentTab: Tabs; - deleting?: BranchLike; - renaming?: BranchLike; } export enum Tabs { @@ -77,87 +71,57 @@ const TABS = [ }, ]; -export default class BranchLikeTabs extends React.PureComponent<Props, State> { - state: State = { currentTab: Tabs.Branch }; - - handleTabSelect = (currentTab: Tabs) => { - this.setState({ currentTab }); - }; - - handleDeleteBranchLike = (branchLike: BranchLike) => { - this.setState({ deleting: branchLike }); - }; - - handleRenameBranchLike = (branchLike: BranchLike) => { - this.setState({ renaming: branchLike }); - }; - - handleUpdatePurgeSetting = () => { - this.props.onBranchesChange(); - }; +export default function BranchLikeTabs(props: Props) { + const { component } = props; + const [currentTab, setCurrentTab] = useState<Tabs>(Tabs.Branch); + const [renaming, setRenaming] = useState<BranchLike>(); - handleClose = () => { - this.setState({ deleting: undefined, renaming: undefined }); - }; + const [deleting, setDeleting] = useState<BranchLike>(); - handleModalActionFulfilled = () => { - this.handleClose(); - this.props.onBranchesChange(); + const handleClose = () => { + setRenaming(undefined); + setDeleting(undefined); }; - render() { - const { branchLikes, component } = this.props; - const { currentTab, deleting, renaming } = this.state; - - const isBranchMode = currentTab === Tabs.Branch; - const branchLikesToDisplay: BranchLike[] = isBranchMode - ? sortBranches(branchLikes.filter(isBranch)) - : sortPullRequests(branchLikes.filter(isPullRequest)); - const title = translate( - isBranchMode - ? 'project_branch_pull_request.table.branch' - : 'project_branch_pull_request.table.pull_request' - ); - - return ( - <> - <BoxedTabs - className="branch-like-tabs" - onSelect={this.handleTabSelect} - selected={currentTab} - tabs={TABS} + const { data: { branchLikes } = { branchLikes: [] } } = useBranchesQuery(component); + + const isBranchMode = currentTab === Tabs.Branch; + const branchLikesToDisplay: BranchLike[] = isBranchMode + ? sortBranches(branchLikes.filter(isBranch) as Branch[]) + : sortPullRequests(branchLikes.filter(isPullRequest) as PullRequest[]); + const title = translate( + isBranchMode + ? 'project_branch_pull_request.table.branch' + : 'project_branch_pull_request.table.pull_request' + ); + + return ( + <> + <BoxedTabs + className="branch-like-tabs" + onSelect={setCurrentTab} + selected={currentTab} + tabs={TABS} + /> + + <div role="tabpanel" id={getTabPanelId(currentTab)} aria-labelledby={getTabId(currentTab)}> + <BranchLikeTable + branchLikes={branchLikesToDisplay} + component={component} + displayPurgeSetting={isBranchMode} + onDelete={setDeleting} + onRename={setRenaming} + title={title} /> + </div> - <div role="tabpanel" id={getTabPanelId(currentTab)} aria-labelledby={getTabId(currentTab)}> - <BranchLikeTable - branchLikes={branchLikesToDisplay} - component={component} - displayPurgeSetting={isBranchMode} - onDelete={this.handleDeleteBranchLike} - onRename={this.handleRenameBranchLike} - onUpdatePurgeSetting={this.handleUpdatePurgeSetting} - title={title} - /> - </div> + {deleting && ( + <DeleteBranchModal branchLike={deleting} component={component} onClose={handleClose} /> + )} - {deleting && ( - <DeleteBranchModal - branchLike={deleting} - component={component} - onClose={this.handleClose} - onDelete={this.handleModalActionFulfilled} - /> - )} - - {renaming && isMainBranch(renaming) && ( - <RenameBranchModal - branch={renaming} - component={component} - onClose={this.handleClose} - onRename={this.handleModalActionFulfilled} - /> - )} - </> - ); - } + {renaming && isMainBranch(renaming) && ( + <RenameBranchModal branch={renaming} component={component} onClose={handleClose} /> + )} + </> + ); } diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchPurgeSetting.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchPurgeSetting.tsx index f6d2551e50b..c69b18521fc 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/components/BranchPurgeSetting.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/components/BranchPurgeSetting.tsx @@ -18,88 +18,44 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { excludeBranchFromPurge } from '../../../api/branches'; import HelpTooltip from '../../../components/controls/HelpTooltip'; import Toggle from '../../../components/controls/Toggle'; import DeferredSpinner from '../../../components/ui/DeferredSpinner'; import { isMainBranch } from '../../../helpers/branch-like'; import { translate } from '../../../helpers/l10n'; +import { useExcludeFromPurgeMutation } from '../../../queries/branch'; import { Branch } from '../../../types/branch-like'; import { Component } from '../../../types/types'; interface Props { branch: Branch; component: Component; - onUpdatePurgeSetting: () => void; } -interface State { - excludedFromPurge: boolean; - loading: boolean; -} - -export default class BranchPurgeSetting extends React.PureComponent<Props, State> { - mounted = false; - - constructor(props: Props) { - super(props); - - this.state = { excludedFromPurge: props.branch.excludedFromPurge, loading: false }; - } - - componentDidMount() { - this.mounted = true; - } +export default function BranchPurgeSetting(props: Props) { + const { branch, component } = props; + const { mutate: excludeFromPurge, isLoading } = useExcludeFromPurgeMutation(); - componentWillUnmount() { - this.mounted = false; - } - - handleOnChange = () => { - const { branch, component } = this.props; - const { excludedFromPurge } = this.state; - const newValue = !excludedFromPurge; - - this.setState({ loading: true }); - - excludeBranchFromPurge(component.key, branch.name, newValue) - .then(() => { - if (this.mounted) { - this.setState({ - excludedFromPurge: newValue, - loading: false, - }); - this.props.onUpdatePurgeSetting(); - } - }) - .catch(() => { - if (this.mounted) { - this.setState({ loading: false }); - } - }); + const handleOnChange = (exclude: boolean) => { + excludeFromPurge({ component, key: branch.name, exclude }); }; - render() { - const { branch } = this.props; - const { excludedFromPurge, loading } = this.state; - - const isTheMainBranch = isMainBranch(branch); - const disabled = isTheMainBranch || loading; - - return ( - <> - <Toggle disabled={disabled} onChange={this.handleOnChange} value={excludedFromPurge} /> - <span className="spacer-left"> - <DeferredSpinner loading={loading} /> - </span> - {isTheMainBranch && ( - <HelpTooltip - overlay={translate( - 'project_branch_pull_request.branch.auto_deletion.main_branch_tooltip' - )} - /> - )} - </> - ); - } + const isTheMainBranch = isMainBranch(branch); + const disabled = isTheMainBranch || isLoading; + + return ( + <> + <Toggle disabled={disabled} onChange={handleOnChange} value={branch.excludedFromPurge} /> + <span className="spacer-left"> + <DeferredSpinner loading={isLoading} /> + </span> + {isTheMainBranch && ( + <HelpTooltip + overlay={translate( + 'project_branch_pull_request.branch.auto_deletion.main_branch_tooltip' + )} + /> + )} + </> + ); } diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx index 9d7d2d422ae..c11ac8febe1 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx @@ -18,11 +18,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { deleteBranch, deletePullRequest } from '../../../api/branches'; -import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import Modal from '../../../components/controls/Modal'; +import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import { getBranchLikeDisplayName, isPullRequest } from '../../../helpers/branch-like'; import { translate, translateWithParameters } from '../../../helpers/l10n'; +import { useDeletBranchMutation } from '../../../queries/branch'; import { BranchLike } from '../../../types/branch-like'; import { Component } from '../../../types/types'; @@ -30,83 +30,50 @@ interface Props { branchLike: BranchLike; component: Component; onClose: () => void; - onDelete: () => void; -} - -interface State { - loading: boolean; } -export default class DeleteBranchModal extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false }; +export default function DeleteBranchModal(props: Props) { + const { branchLike, component } = props; + const { mutate: deleteBranch, isLoading } = useDeletBranchMutation(); - componentDidMount() { - this.mounted = true; - } - - componentWillUnmount() { - this.mounted = false; - } - - handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { + const handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); - this.setState({ loading: true }); - const request = isPullRequest(this.props.branchLike) - ? deletePullRequest({ - project: this.props.component.key, - pullRequest: this.props.branchLike.key, - }) - : deleteBranch({ - branch: this.props.branchLike.name, - project: this.props.component.key, - }); - request.then( - () => { - if (this.mounted) { - this.setState({ loading: false }); - this.props.onDelete(); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } + deleteBranch( + { component, branchLike }, + { + onSuccess: props.onClose, } ); }; - render() { - const { branchLike } = this.props; - const header = translate( - isPullRequest(branchLike) - ? 'project_branch_pull_request.pull_request.delete' - : 'project_branch_pull_request.branch.delete' - ); + const header = translate( + isPullRequest(branchLike) + ? 'project_branch_pull_request.pull_request.delete' + : 'project_branch_pull_request.branch.delete' + ); - return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose}> - <header className="modal-head"> - <h2>{header}</h2> - </header> - <form onSubmit={this.handleSubmit}> - <div className="modal-body"> - {translateWithParameters( - isPullRequest(branchLike) - ? 'project_branch_pull_request.pull_request.delete.are_you_sure' - : 'project_branch_pull_request.branch.delete.are_you_sure', - getBranchLikeDisplayName(branchLike) - )} - </div> - <footer className="modal-foot"> - {this.state.loading && <i className="spinner spacer-right" />} - <SubmitButton className="button-red" disabled={this.state.loading}> - {translate('delete')} - </SubmitButton> - <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink> - </footer> - </form> - </Modal> - ); - } + return ( + <Modal contentLabel={header} onRequestClose={props.onClose}> + <header className="modal-head"> + <h2>{header}</h2> + </header> + <form onSubmit={handleSubmit}> + <div className="modal-body"> + {translateWithParameters( + isPullRequest(branchLike) + ? 'project_branch_pull_request.pull_request.delete.are_you_sure' + : 'project_branch_pull_request.branch.delete.are_you_sure', + getBranchLikeDisplayName(branchLike) + )} + </div> + <footer className="modal-foot"> + {isLoading && <i className="spinner spacer-right" />} + <SubmitButton className="button-red" disabled={isLoading}> + {translate('delete')} + </SubmitButton> + <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink> + </footer> + </form> + </Modal> + ); } diff --git a/server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx b/server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx index 3b8580ce24e..d92b0794d42 100644 --- a/server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx +++ b/server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx @@ -18,12 +18,13 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { renameBranch } from '../../../api/branches'; -import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; +import { useState } from 'react'; import Modal from '../../../components/controls/Modal'; +import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons'; import MandatoryFieldMarker from '../../../components/ui/MandatoryFieldMarker'; import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; import { translate } from '../../../helpers/l10n'; +import { useRenameMainBranchMutation } from '../../../queries/branch'; import { MainBranch } from '../../../types/branch-like'; import { Component } from '../../../types/types'; @@ -31,90 +32,62 @@ interface Props { branch: MainBranch; component: Component; onClose: () => void; - onRename: () => void; -} - -interface State { - loading: boolean; - name?: string; } -export default class RenameBranchModal extends React.PureComponent<Props, State> { - mounted = false; - state: State = { loading: false }; - - componentDidMount() { - this.mounted = true; - } +export default function RenameBranchModal(props: Props) { + const { branch, component } = props; + const [name, setName] = useState<string>(); - componentWillUnmount() { - this.mounted = false; - } + const { mutate: renameMainBranch, isLoading } = useRenameMainBranchMutation(); - handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { + const handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => { event.preventDefault(); - if (!this.state.name) { + if (!name) { return; } - this.setState({ loading: true }); - renameBranch(this.props.component.key, this.state.name).then( - () => { - if (this.mounted) { - this.setState({ loading: false }); - this.props.onRename(); - } - }, - () => { - if (this.mounted) { - this.setState({ loading: false }); - } - } - ); + + renameMainBranch({ component, name }, { onSuccess: props.onClose }); }; - handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => { - this.setState({ name: event.currentTarget.value }); + const handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) => { + setName(event.currentTarget.value); }; - render() { - const { branch } = this.props; - const header = translate('project_branch_pull_request.branch.rename'); - const submitDisabled = - this.state.loading || !this.state.name || this.state.name === branch.name; + const header = translate('project_branch_pull_request.branch.rename'); + const submitDisabled = isLoading || !name || name === branch.name; - return ( - <Modal contentLabel={header} onRequestClose={this.props.onClose} size="small"> - <header className="modal-head"> - <h2>{header}</h2> - </header> - <form onSubmit={this.handleSubmit}> - <div className="modal-body"> - <MandatoryFieldsExplanation className="modal-field" /> - <div className="modal-field"> - <label htmlFor="rename-branch-name"> - {translate('new_name')} - <MandatoryFieldMarker /> - </label> - <input - autoFocus - id="rename-branch-name" - maxLength={100} - name="name" - onChange={this.handleNameChange} - required - size={50} - type="text" - value={this.state.name !== undefined ? this.state.name : branch.name} - /> - </div> + return ( + <Modal contentLabel={header} onRequestClose={props.onClose} size="small"> + <header className="modal-head"> + <h2>{header}</h2> + </header> + <form onSubmit={handleSubmit}> + <div className="modal-body"> + <MandatoryFieldsExplanation className="modal-field" /> + <div className="modal-field"> + <label htmlFor="rename-branch-name"> + {translate('new_name')} + <MandatoryFieldMarker /> + </label> + <input + autoFocus + id="rename-branch-name" + maxLength={100} + name="name" + onChange={handleNameChange} + required + size={50} + type="text" + value={name ?? branch.name} + /> </div> - <footer className="modal-foot"> - {this.state.loading && <i className="spinner spacer-right" />} - <SubmitButton disabled={submitDisabled}>{translate('rename')}</SubmitButton> - <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink> - </footer> - </form> - </Modal> - ); - } + </div> + <footer className="modal-foot"> + {isLoading && <i className="spinner spacer-right" />} + <SubmitButton disabled={submitDisabled}>{translate('rename')}</SubmitButton> + <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink> + </footer> + </form> + </Modal> + ); } diff --git a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/CoverageFilter-test.tsx b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/CoverageFilter-test.tsx index 5a1798e26a1..bbb45b3b947 100644 --- a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/CoverageFilter-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/CoverageFilter-test.tsx @@ -21,7 +21,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; -import { FCProps } from '../../../../helpers/testUtils'; +import { ComponentPropsType } from '../../../../helpers/testUtils'; import CoverageFilter from '../CoverageFilter'; it('renders options', () => { @@ -47,7 +47,7 @@ it('updates the filter query', async () => { expect(onQueryChange).toHaveBeenCalledWith({ coverage: '3' }); }); -function renderCoverageFilter(props: Partial<FCProps<typeof CoverageFilter>> = {}) { +function renderCoverageFilter(props: Partial<ComponentPropsType<typeof CoverageFilter>> = {}) { renderComponent( <CoverageFilter maxFacetValue={9} diff --git a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/LanguagesFilter-test.tsx b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/LanguagesFilter-test.tsx index 80fdaf6143a..a8f55537c86 100644 --- a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/LanguagesFilter-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/LanguagesFilter-test.tsx @@ -21,7 +21,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; -import { FCProps } from '../../../../helpers/testUtils'; +import { ComponentPropsType } from '../../../../helpers/testUtils'; import { LanguagesFilter } from '../LanguagesFilter'; it('renders language names', () => { @@ -61,7 +61,7 @@ it('updates the filter query', async () => { expect(onQueryChange).toHaveBeenCalledWith({ languages: 'java' }); }); -function renderLanguagesFilter(props: Partial<FCProps<typeof LanguagesFilter>> = {}) { +function renderLanguagesFilter(props: Partial<ComponentPropsType<typeof LanguagesFilter>> = {}) { renderComponent( <LanguagesFilter languages={{ diff --git a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/QualityGateFilter-test.tsx b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/QualityGateFilter-test.tsx index 699da7601bf..c21ab619191 100644 --- a/server/sonar-web/src/main/js/apps/projects/filters/__tests__/QualityGateFilter-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/filters/__tests__/QualityGateFilter-test.tsx @@ -21,7 +21,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { renderComponent } from '../../../../helpers/testReactTestingUtils'; -import { FCProps } from '../../../../helpers/testUtils'; +import { ComponentPropsType } from '../../../../helpers/testUtils'; import QualityGateFacet from '../QualityGateFilter'; it('renders options', () => { @@ -57,7 +57,7 @@ it('handles multiselection', async () => { expect(onQueryChange).toHaveBeenCalledWith({ gate: 'OK,ERROR' }); }); -function renderQualityGateFilter(props: Partial<FCProps<typeof QualityGateFacet>> = {}) { +function renderQualityGateFilter(props: Partial<ComponentPropsType<typeof QualityGateFacet>> = {}) { renderComponent( <QualityGateFacet maxFacetValue={9} diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx index 0b5d3eae408..3d503e44137 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx @@ -21,7 +21,6 @@ import { flatMap, range } from 'lodash'; import * as React from 'react'; import { getMeasures } from '../../api/measures'; import { getSecurityHotspotList, getSecurityHotspots } from '../../api/security-hotspots'; -import withBranchStatusActions from '../../app/components/branch-status/withBranchStatusActions'; import withComponentContext from '../../app/components/componentContext/withComponentContext'; import withCurrentUserContext from '../../app/components/current-user/withCurrentUserContext'; import { Location, Router, withRouter } from '../../components/hoc/withRouter'; @@ -30,6 +29,7 @@ import { getBranchLikeQuery, isPullRequest, isSameBranchLike } from '../../helpe import { isInput } from '../../helpers/keyboardEventHelpers'; import { KeyboardKeys } from '../../helpers/keycodes'; import { getStandards } from '../../helpers/security-standard'; +import { withBranchLikes } from '../../queries/branch'; import { BranchLike } from '../../types/branch-like'; import { SecurityStandard, Standards } from '../../types/security'; import { @@ -46,11 +46,8 @@ import './styles.css'; import { SECURITY_STANDARDS, getLocations } from './utils'; const PAGE_SIZE = 500; -interface DispatchProps { - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => void; -} -interface OwnProps { +interface Props { branchLike?: BranchLike; currentUser: CurrentUser; component: Component; @@ -58,8 +55,6 @@ interface OwnProps { router: Router; } -type Props = DispatchProps & OwnProps; - interface State { filterByCategory?: { standard: SecurityStandard; category: string }; filterByCWE?: string; @@ -117,6 +112,7 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { componentDidUpdate(previous: Props) { if ( + !isSameBranchLike(this.props.branchLike, previous.branchLike) || this.props.component.key !== previous.component.key || this.props.location.query.hotspots !== previous.location.query.hotspots || SECURITY_STANDARDS.some((s) => this.props.location.query[s] !== previous.location.query[s]) || @@ -434,13 +430,8 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { handleHotspotUpdate = (hotspotKey: string) => { const { hotspots, hotspotsPageIndex } = this.state; - const { branchLike, component } = this.props; const index = hotspots.findIndex((h) => h.key === hotspotKey); - if (isPullRequest(branchLike)) { - this.props.fetchBranchStatus(branchLike, component.key); - } - return Promise.all( range(hotspotsPageIndex).map((p) => this.fetchSecurityHotspots(p + 1 /* pages are 1-indexed */) @@ -550,5 +541,5 @@ export class SecurityHotspotsApp extends React.PureComponent<Props, State> { } export default withRouter( - withComponentContext(withCurrentUserContext(withBranchStatusActions(SecurityHotspotsApp))) + withComponentContext(withCurrentUserContext(withBranchLikes(SecurityHotspotsApp))) ); diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx index 69f8f057701..3806173420e 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx @@ -21,11 +21,11 @@ import { act, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { Route } from 'react-router-dom'; +import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import CodingRulesServiceMock from '../../../api/mocks/CodingRulesServiceMock'; import SecurityHotspotServiceMock from '../../../api/mocks/SecurityHotspotServiceMock'; import { getSecurityHotspots, setSecurityHotspotStatus } from '../../../api/security-hotspots'; import { searchUsers } from '../../../api/users'; -import { mockBranch, mockMainBranch } from '../../../helpers/mocks/branch-like'; import { mockComponent } from '../../../helpers/mocks/component'; import { openHotspot, probeSonarLintServers } from '../../../helpers/sonarlint'; import { get, save } from '../../../helpers/storage'; @@ -107,6 +107,7 @@ const ui = { const originalScrollTo = window.scrollTo; const hotspotsHandler = new SecurityHotspotServiceMock(); const rulesHandles = new CodingRulesServiceMock(); +const branchHandler = new BranchesServiceMock(); let showDialog = 'true'; jest.mocked(save).mockImplementation((_key: string, value?: string) => { @@ -143,6 +144,7 @@ beforeEach(() => { afterEach(() => { hotspotsHandler.reset(); rulesHandles.reset(); + branchHandler.reset(); }); describe('rendering', () => { @@ -309,6 +311,7 @@ describe('navigation', () => { const user = userEvent.setup(); renderSecurityHotspotsApp(); + expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument(); await user.keyboard('{ArrowDown}'); expect(await ui.hotspotTitle(/'2' is a magic number./).find()).toBeInTheDocument(); await user.keyboard('{ArrowUp}'); @@ -343,16 +346,13 @@ describe('navigation', () => { const rtl = renderSecurityHotspotsApp( 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=test-1' ); - expect(await ui.hotspotTitle(/'3' is a magic number./).find()).toBeInTheDocument(); // On specific branch rtl.unmount(); renderSecurityHotspotsApp( - 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=b1-test-1&branch=b1', - { branchLike: mockBranch({ name: 'b1' }) } + 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed&hotspots=b1-test-1&branch=normal-branch' ); - expect(await ui.hotspotTitle(/'F' is a magic number./).find()).toBeInTheDocument(); }); @@ -417,7 +417,7 @@ it('should be able to filter the hotspot list', async () => { await user.click(ui.filterDropdown.get()); await user.click(ui.filterAssigneeToMe.get()); - expect(ui.noHotspotForFilter.get()).toBeInTheDocument(); + expect(await ui.noHotspotForFilter.find()).toBeInTheDocument(); await user.click(ui.filterToReview.get()); @@ -432,7 +432,7 @@ it('should be able to filter the hotspot list', async () => { }); await user.click(ui.filterDropdown.get()); - await user.click(ui.filterNewCode.get()); + await user.click(await ui.filterNewCode.find()); expect(getSecurityHotspots).toHaveBeenLastCalledWith({ inNewCodePeriod: true, @@ -458,15 +458,15 @@ function renderSecurityHotspotsApp( 'security_hotspots', () => <Route path="security_hotspots" element={<SecurityHotspotsApp />} />, { - navigateTo, + navigateTo: + navigateTo ?? + 'security_hotspots?id=guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', currentUser: mockLoggedInUser({ login: 'foo', name: 'foo', }), }, { - branchLike: mockMainBranch(), - onBranchesChange: jest.fn(), onComponentChange: jest.fn(), component: mockComponent({ key: 'guillaume-peoch-sonarsource_benflix_AYGpXq2bd8qy4i0eO9ed', diff --git a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx index 55ec92e4390..ba7c40d5266 100644 --- a/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx +++ b/server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx @@ -41,6 +41,7 @@ import { getPathUrlAsString, getRuleUrl, } from '../../../helpers/urls'; +import { useRefreshBranchStatus } from '../../../queries/branch'; import { BranchLike } from '../../../types/branch-like'; import { SecurityStandard, Standards } from '../../../types/security'; import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots'; @@ -68,6 +69,7 @@ interface StyledHeaderProps { export function HotspotHeader(props: HotspotHeaderProps) { const { hotspot, component, branchLike, standards, tabs, isCompressed, isScrolled } = props; const { message, messageFormattings, rule, key } = hotspot; + const refrechBranchStatus = useRefreshBranchStatus(); const permalink = getPathUrlAsString( getComponentSecurityHotspotsUrl(component.key, { @@ -78,14 +80,15 @@ export function HotspotHeader(props: HotspotHeaderProps) { ); const categoryStandard = standards?.[SecurityStandard.SONARSOURCE][rule.securityCategory]?.title; + const handleStatusChange = async (statusOption: HotspotStatusOption) => { + await props.onUpdateHotspot(true, statusOption); + refrechBranchStatus(); + }; const content = isCompressed ? ( <div className="sw-flex sw-justify-between"> {tabs} - <StatusReviewButton - hotspot={hotspot} - onStatusChange={(statusOption) => props.onUpdateHotspot(true, statusOption)} - /> + <StatusReviewButton hotspot={hotspot} onStatusChange={handleStatusChange} /> </div> ) : ( <> @@ -110,10 +113,7 @@ export function HotspotHeader(props: HotspotHeaderProps) { {rule.key} </Link> </div> - <Status - hotspot={hotspot} - onStatusChange={(statusOption) => props.onUpdateHotspot(true, statusOption)} - /> + <Status hotspot={hotspot} onStatusChange={handleStatusChange} /> </div> <div className="sw-flex sw-flex-col sw-gap-4"> <HotspotHeaderRightSection diff --git a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx index 1f2868d2870..cba93f8f925 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx @@ -77,7 +77,6 @@ export interface Props { highlightedLocationMessage?: { index: number; text: string | undefined }; onLoaded?: (component: SourceViewerFile, sources: SourceLine[], issues: Issue[]) => void; onLocationSelect?: (index: number) => void; - onIssueChange?: (issue: Issue) => void; onIssueSelect?: (issueKey: string) => void; onIssueUnselect?: () => void; selectedIssue?: string; @@ -466,9 +465,6 @@ export default class SourceViewer extends React.PureComponent<Props, State> { ); return { issues: newIssues, issuesByLine: issuesByLine(newIssues) }; }); - if (this.props.onIssueChange) { - this.props.onIssueChange(issue); - } }; renderDuplicationPopup = (index: number, line: number) => { diff --git a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx index e067668bf1e..39989709266 100644 --- a/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx +++ b/server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx @@ -370,7 +370,6 @@ function renderSourceViewer(override?: Partial<SourceViewer['props']>) { component={componentsHandler.getNonEmptyFileKey()} displayAllIssues displayLocationMarkers - onIssueChange={jest.fn()} onIssueSelect={jest.fn()} onLoaded={jest.fn()} onLocationSelect={jest.fn()} @@ -385,7 +384,6 @@ function renderSourceViewer(override?: Partial<SourceViewer['props']>) { component={componentsHandler.getNonEmptyFileKey()} displayAllIssues displayLocationMarkers - onIssueChange={jest.fn()} onIssueSelect={jest.fn()} onLoaded={jest.fn()} onLocationSelect={jest.fn()} diff --git a/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx b/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx index 31996b65f5a..fa87946a783 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx @@ -21,6 +21,7 @@ import { Note } from 'design-system'; import * as React from 'react'; import { ComponentContext } from '../../app/components/componentContext/ComponentContext'; import { translate } from '../../helpers/l10n'; +import { useBranchesQuery } from '../../queries/branch'; import { AnalysisEvent } from '../../types/project-activity'; import Tooltip from '../controls/Tooltip'; import { DefinitionChangeEventInner, isDefinitionChangeEvent } from './DefinitionChangeEventInner'; @@ -32,16 +33,12 @@ export interface EventInnerProps { } export default function EventInner({ event, readonly }: EventInnerProps) { + const { component } = React.useContext(ComponentContext); + const { data: { branchLike } = {} } = useBranchesQuery(component); if (isRichQualityGateEvent(event)) { return <RichQualityGateEventInner event={event} readonly={readonly} />; } else if (isDefinitionChangeEvent(event)) { - return ( - <ComponentContext.Consumer> - {({ branchLike }) => ( - <DefinitionChangeEventInner branchLike={branchLike} event={event} readonly={readonly} /> - )} - </ComponentContext.Consumer> - ); + return <DefinitionChangeEventInner branchLike={branchLike} event={event} readonly={readonly} />; } return ( <Tooltip overlay={event.description}> diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx b/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx index 79cdb85ca3b..8f1d75b645d 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx @@ -26,7 +26,7 @@ import { mockHistoryItem, mockMeasureHistory } from '../../../helpers/mocks/proj import { mockMetric } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; import { byLabelText, byPlaceholderText, byRole, byText } from '../../../helpers/testSelector'; -import { FCProps } from '../../../helpers/testUtils'; +import { ComponentPropsType } from '../../../helpers/testUtils'; import { MetricKey } from '../../../types/metrics'; import { GraphType, MeasureHistory } from '../../../types/project-activity'; import { Metric } from '../../../types/types'; @@ -238,7 +238,7 @@ function getPageObject() { function renderActivityGraph( graphsHistoryProps: Partial<GraphsHistory['props']> = {}, - graphsHeaderProps: Partial<FCProps<typeof GraphsHeader>> = {} + graphsHeaderProps: Partial<ComponentPropsType<typeof GraphsHeader>> = {} ) { function ActivityGraph() { const [selectedMetrics, setSelectedMetrics] = React.useState<string[]>([]); diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx b/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx index 46540d728c7..3ba9228a84a 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx @@ -21,13 +21,13 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { Route } from 'react-router-dom'; +import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; import { isMainBranch } from '../../../helpers/branch-like'; import { mockBranch, mockMainBranch } from '../../../helpers/mocks/branch-like'; import { mockAnalysisEvent } from '../../../helpers/mocks/project-activity'; import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; import { byRole, byText } from '../../../helpers/testSelector'; -import { BranchLike } from '../../../types/branch-like'; -import { ComponentContextShape } from '../../../types/component'; +import { Branch, BranchLike } from '../../../types/branch-like'; import { ApplicationAnalysisEventCategory, DefinitionChangeType, @@ -43,8 +43,8 @@ const ui = { definitionChangeLabel: byText('event.category.DEFINITION_CHANGE', { exact: false }), projectAddedTxt: (branch: BranchLike) => isMainBranch(branch) - ? byText('event.definition_change.added') - : byText('event.definition_change.branch_added'), + ? byText(/event\.definition_change\.added/) + : byText(/event\.definition_change\.branch_added/), projectRemovedTxt: (branch: BranchLike) => isMainBranch(branch) ? byText('event.definition_change.removed') @@ -57,10 +57,17 @@ const ui = { versionLabel: byText('event.category.VERSION', { exact: false }), }; +const handler = new BranchesServiceMock(); + +beforeEach(() => { + handler.reset(); +}); + describe('DEFINITION_CHANGE events', () => { it.each([mockMainBranch(), mockBranch()])( 'should render correctly for "ADDED" events', - async (branchLike: BranchLike) => { + async (branchLike: Branch) => { + handler.addBranch(branchLike); const user = userEvent.setup(); renderEventInner( { @@ -78,14 +85,14 @@ describe('DEFINITION_CHANGE events', () => { }, }), }, - { branchLike } + `branch=${branchLike.name}&id=my-project` ); - expect(ui.definitionChangeLabel.get()).toBeInTheDocument(); + expect(await ui.definitionChangeLabel.find()).toBeInTheDocument(); await user.click(ui.showMoreBtn.get()); - expect(ui.projectAddedTxt(branchLike).get()).toBeInTheDocument(); + expect(await ui.projectAddedTxt(branchLike).find()).toBeInTheDocument(); expect(ui.projectLink('Foo').get()).toBeInTheDocument(); expect(screen.getByText('master-foo')).toBeInTheDocument(); } @@ -93,8 +100,9 @@ describe('DEFINITION_CHANGE events', () => { it.each([mockMainBranch(), mockBranch()])( 'should render correctly for "REMOVED" events', - async (branchLike: BranchLike) => { + async (branchLike: Branch) => { const user = userEvent.setup(); + handler.addBranch(branchLike); renderEventInner( { event: mockAnalysisEvent({ @@ -111,14 +119,14 @@ describe('DEFINITION_CHANGE events', () => { }, }), }, - { branchLike } + `branch=${branchLike.name}&id=my-project` ); expect(ui.definitionChangeLabel.get()).toBeInTheDocument(); await user.click(ui.showMoreBtn.get()); - expect(ui.projectRemovedTxt(branchLike).get()).toBeInTheDocument(); + expect(await ui.projectRemovedTxt(branchLike).find()).toBeInTheDocument(); expect(ui.projectLink('Bar').get()).toBeInTheDocument(); expect(screen.getByText('master-bar')).toBeInTheDocument(); } @@ -228,14 +236,10 @@ describe('VERSION events', () => { }); }); -function renderEventInner( - props: Partial<EventInnerProps> = {}, - componentContext: Partial<ComponentContextShape> = {} -) { +function renderEventInner(props: Partial<EventInnerProps> = {}, params?: string) { return renderAppWithComponentContext( '/', () => <Route path="*" element={<EventInner event={mockAnalysisEvent()} {...props} />} />, - {}, - componentContext + { navigateTo: params ? `/?id=my-project&${params}` : '/?id=my-project' } ); } diff --git a/server/sonar-web/src/main/js/components/common/BranchStatus.tsx b/server/sonar-web/src/main/js/components/common/BranchStatus.tsx index de5ef2406d0..016a54b3447 100644 --- a/server/sonar-web/src/main/js/components/common/BranchStatus.tsx +++ b/server/sonar-web/src/main/js/components/common/BranchStatus.tsx @@ -18,20 +18,19 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import withBranchStatus from '../../app/components/branch-status/withBranchStatus'; import Level from '../../components/ui/Level'; -import { BranchStatusData } from '../../types/branch-like'; +import { BranchLike } from '../../types/branch-like'; -export type BranchStatusProps = Pick<BranchStatusData, 'status'>; +export interface BranchStatusProps { + branchLike: BranchLike; +} -export function BranchStatus(props: BranchStatusProps) { - const { status } = props; +export default function BranchStatus(props: BranchStatusProps) { + const { branchLike } = props; - if (!status) { + if (!branchLike.status) { return null; } - return <Level level={status} small />; + return <Level level={branchLike.status.qualityGateStatus} small />; } - -export default withBranchStatus(BranchStatus); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx index 28bb2f1ddf8..ae64ae53f60 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx @@ -18,13 +18,14 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { screen, waitFor } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import * as React from 'react'; -import { getTask } from '../../../api/ce'; +import { AnalysisWarningsModal } from '../../../app/components/nav/component/AnalysisWarningsModal'; +import { mockComponent } from '../../../helpers/mocks/component'; import { mockTaskWarning } from '../../../helpers/mocks/tasks'; import { mockCurrentUser } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; -import { AnalysisWarningsModal } from '../AnalysisWarningsModal'; +import { ComponentPropsType } from '../../../helpers/testUtils'; jest.mock('../../../api/ce', () => ({ dismissAnalysisWarning: jest.fn().mockResolvedValue(null), @@ -60,26 +61,12 @@ describe('should render correctly', () => { }); }); -it('should not fetch task warnings if it does not have to', () => { - renderAnalysisWarningsModal(); - - expect(getTask).not.toHaveBeenCalled(); -}); - -it('should fetch task warnings if it has to', async () => { - renderAnalysisWarningsModal({ taskId: 'abcd1234', warnings: undefined }); - - expect(screen.queryByText('message foo')).not.toBeInTheDocument(); - expect(getTask).toHaveBeenCalledWith('abcd1234', ['warnings']); - - await waitFor(() => { - expect(screen.getByText('message foo')).toBeInTheDocument(); - }); -}); - -function renderAnalysisWarningsModal(props: Partial<AnalysisWarningsModal['props']> = {}) { +function renderAnalysisWarningsModal( + props: Partial<ComponentPropsType<typeof AnalysisWarningsModal>> = {} +) { return renderComponent( <AnalysisWarningsModal + component={mockComponent()} currentUser={mockCurrentUser({ isLoggedIn: true })} onClose={jest.fn()} warnings={[ diff --git a/server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx index 886fd31dfbc..d2c1b766aee 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx @@ -17,24 +17,34 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { shallow } from 'enzyme'; +import { screen } from '@testing-library/react'; import * as React from 'react'; -import { BranchStatus, BranchStatusProps } from '../BranchStatus'; +import BranchesServiceMock from '../../../api/mocks/BranchesServiceMock'; +import { mockBranch } from '../../../helpers/mocks/branch-like'; +import { renderComponent } from '../../../helpers/testReactTestingUtils'; +import BranchStatus, { BranchStatusProps } from '../BranchStatus'; -it('should render correctly', () => { - expect(shallowRender().type()).toBeNull(); - expect( - shallowRender({ - status: 'OK', - }) - ).toMatchSnapshot('Successful'); - expect( - shallowRender({ - status: 'ERROR', - }) - ).toMatchSnapshot('Error'); +const handler = new BranchesServiceMock(); + +beforeEach(() => { + handler.reset(); +}); + +it('should render ok status', async () => { + renderBranchStatus({ branchLike: mockBranch({ status: { qualityGateStatus: 'OK' } }) }); + + expect(await screen.findByText('OK')).toBeInTheDocument(); +}); + +it('should render error status', async () => { + renderBranchStatus({ branchLike: mockBranch({ status: { qualityGateStatus: 'ERROR' } }) }); + + expect(await screen.findByText('ERROR')).toBeInTheDocument(); }); -function shallowRender(overrides: Partial<BranchStatusProps> = {}) { - return shallow(<BranchStatus {...overrides} />); +function renderBranchStatus(overrides: Partial<BranchStatusProps> = {}) { + const defaultProps = { + branchLike: mockBranch(), + } as const; + return renderComponent(<BranchStatus {...defaultProps} {...overrides} />); } diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap deleted file mode 100644 index aeb2bda6418..00000000000 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly: Error 1`] = ` -<Level - level="ERROR" - small={true} -/> -`; - -exports[`should render correctly: Successful 1`] = ` -<Level - level="OK" - small={true} -/> -`; diff --git a/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx b/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx index 1794ecd47c7..9aed4c3dcb4 100644 --- a/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx +++ b/server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx @@ -21,7 +21,7 @@ import styled from '@emotion/styled'; import * as React from 'react'; import { colors, sizes } from '../../app/theme'; -export interface BoxedTabsProps<K extends string | number> { +export interface BoxedTabsProps<K> { className?: string; onSelect: (key: K) => void; selected?: K; @@ -72,7 +72,7 @@ const ActiveBorder = styled.div<{ active: boolean }>` top: -1px; `; -export default function BoxedTabs<K extends string | number>(props: BoxedTabsProps<K>) { +export default function BoxedTabs<K>(props: BoxedTabsProps<K>) { const { className, tabs, selected } = props; return ( @@ -96,10 +96,10 @@ export default function BoxedTabs<K extends string | number>(props: BoxedTabsPro ); } -export function getTabPanelId(key: string | number) { +export function getTabPanelId<K>(key: K) { return `tabpanel-${key}`; } -export function getTabId(key: string | number) { +export function getTabId<K>(key: K) { return `tab-${key}`; } diff --git a/server/sonar-web/src/main/js/components/issue/Issue.tsx b/server/sonar-web/src/main/js/components/issue/Issue.tsx index d1f7bd57f85..760025788b6 100644 --- a/server/sonar-web/src/main/js/components/issue/Issue.tsx +++ b/server/sonar-web/src/main/js/components/issue/Issue.tsx @@ -17,11 +17,14 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { flow } from 'lodash'; import * as React from 'react'; +import { useCallback } from 'react'; import { setIssueAssignee } from '../../api/issues'; import { isInput, isShortcut } from '../../helpers/keyboardEventHelpers'; import { KeyboardKeys } from '../../helpers/keycodes'; import { getKeyboardShortcutEnabled } from '../../helpers/preferences'; +import { useRefreshBranchStatus } from '../../queries/branch'; import { BranchLike } from '../../types/branch-like'; import { Issue as TypeIssue } from '../../types/types'; import { updateIssue } from './actions'; @@ -41,89 +44,93 @@ interface Props { selected: boolean; } -export default class Issue extends React.PureComponent<Props> { - static defaultProps = { - selected: false, - }; +export default function Issue(props: Props) { + const { + selected = false, + issue, + branchLike, + checked, + openPopup, + displayWhyIsThisAnIssue, + onCheck, + onPopupToggle, + } = props; - componentDidMount() { - if (this.props.selected) { - document.addEventListener('keydown', this.handleKeyDown, { capture: true }); - } - } + const refreshStatus = useRefreshBranchStatus(); - componentDidUpdate(prevProps: Props) { - if (!prevProps.selected && this.props.selected) { - document.addEventListener('keydown', this.handleKeyDown, { capture: true }); - } else if (prevProps.selected && !this.props.selected) { - document.removeEventListener('keydown', this.handleKeyDown, { capture: true }); - } - } + const onChange = flow([props.onChange, refreshStatus]); - componentWillUnmount() { - if (this.props.selected) { - document.removeEventListener('keydown', this.handleKeyDown, { capture: true }); - } - } + const togglePopup = useCallback( + (popupName: string, open?: boolean) => { + onPopupToggle(issue.key, popupName, open); + }, + [issue.key, onPopupToggle] + ); - handleKeyDown = (event: KeyboardEvent) => { - if (!getKeyboardShortcutEnabled() || isInput(event) || isShortcut(event)) { - return true; - } else if (event.key === KeyboardKeys.KeyF) { - event.preventDefault(); - return this.togglePopup('transition'); - } else if (event.key === KeyboardKeys.KeyA) { - event.preventDefault(); - return this.togglePopup('assign'); - } else if (event.key === KeyboardKeys.KeyM && this.props.issue.actions.includes('assign')) { - event.preventDefault(); - return this.handleAssignement('_me'); - } else if (event.key === KeyboardKeys.KeyI) { - event.preventDefault(); - return this.togglePopup('set-severity'); - } else if (event.key === KeyboardKeys.KeyC) { - event.preventDefault(); - return this.togglePopup('comment'); - } else if (event.key === KeyboardKeys.KeyT) { - event.preventDefault(); - return this.togglePopup('edit-tags'); - } else if (event.key === KeyboardKeys.Space) { - event.preventDefault(); - if (this.props.onCheck) { - return this.props.onCheck(this.props.issue.key); + const handleAssignement = useCallback( + (login: string) => { + if (issue.assignee !== login) { + updateIssue(onChange, setIssueAssignee({ issue: issue.key, assignee: login })); } - } - return true; - }; + togglePopup('assign', false); + }, + [issue.assignee, issue.key, onChange, togglePopup] + ); - togglePopup = (popupName: string, open?: boolean) => { - this.props.onPopupToggle(this.props.issue.key, popupName, open); - }; + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!getKeyboardShortcutEnabled() || isInput(event) || isShortcut(event)) { + return true; + } else if (event.key === KeyboardKeys.KeyF) { + event.preventDefault(); + return togglePopup('transition'); + } else if (event.key === KeyboardKeys.KeyA) { + event.preventDefault(); + return togglePopup('assign'); + } else if (event.key === KeyboardKeys.KeyM && issue.actions.includes('assign')) { + event.preventDefault(); + return handleAssignement('_me'); + } else if (event.key === KeyboardKeys.KeyI) { + event.preventDefault(); + return togglePopup('set-severity'); + } else if (event.key === KeyboardKeys.KeyC) { + event.preventDefault(); + return togglePopup('comment'); + } else if (event.key === KeyboardKeys.KeyT) { + event.preventDefault(); + return togglePopup('edit-tags'); + } else if (event.key === KeyboardKeys.Space) { + event.preventDefault(); + if (onCheck) { + return onCheck(issue.key); + } + } + return true; + }, + [issue.actions, issue.key, togglePopup, handleAssignement, onCheck] + ); - handleAssignement = (login: string) => { - const { issue } = this.props; - if (issue.assignee !== login) { - updateIssue(this.props.onChange, setIssueAssignee({ issue: issue.key, assignee: login })); + React.useEffect(() => { + if (selected) { + document.addEventListener('keydown', handleKeyDown, { capture: true }); } - this.togglePopup('assign', false); - }; + return () => document.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, [handleKeyDown, selected]); - render() { - return ( - <IssueView - branchLike={this.props.branchLike} - checked={this.props.checked} - currentPopup={this.props.openPopup} - displayWhyIsThisAnIssue={this.props.displayWhyIsThisAnIssue} - issue={this.props.issue} - onAssign={this.handleAssignement} - onChange={this.props.onChange} - onCheck={this.props.onCheck} - onClick={this.props.onClick} - onSelect={this.props.onSelect} - selected={this.props.selected} - togglePopup={this.togglePopup} - /> - ); - } + return ( + <IssueView + branchLike={branchLike} + checked={checked} + currentPopup={openPopup} + displayWhyIsThisAnIssue={displayWhyIsThisAnIssue} + issue={issue} + onAssign={handleAssignement} + onChange={onChange} + onCheck={props.onCheck} + onClick={props.onClick} + onSelect={props.onSelect} + selected={selected} + togglePopup={togglePopup} + /> + ); } diff --git a/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx b/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx index e8bac03ed08..2560444a0b1 100644 --- a/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx +++ b/server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx @@ -28,6 +28,7 @@ import { KeyboardKeys } from '../../../helpers/keycodes'; import { mockIssue, mockLoggedInUser, mockRawIssue } from '../../../helpers/testMocks'; import { renderAppRoutes } from '../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byText } from '../../../helpers/testSelector'; +import { ComponentPropsType } from '../../../helpers/testUtils'; import { IssueActions, IssueSeverity, @@ -416,8 +417,12 @@ function getPageObject() { return { ui, user }; } -function renderIssue(props: Partial<Omit<Issue['props'], 'onChange' | 'onPopupToggle'>> = {}) { - function Wrapper(wrapperProps: Omit<Issue['props'], 'onChange' | 'onPopupToggle'>) { +function renderIssue( + props: Partial<Omit<ComponentPropsType<typeof Issue>, 'onChange' | 'onPopupToggle'>> = {} +) { + function Wrapper( + wrapperProps: Omit<ComponentPropsType<typeof Issue>, 'onChange' | 'onPopupToggle'> + ) { const [issue, setIssue] = React.useState(wrapperProps.issue); const [openPopup, setOpenPopup] = React.useState<string | undefined>(); return ( 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 83ebf05e9aa..3cb64f78607 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx @@ -21,12 +21,9 @@ import * as React from 'react'; import { getAlmSettingsNoCatch } from '../../api/alm-settings'; import { getScannableProjects } from '../../api/components'; import { getValue } from '../../api/settings'; -import { ComponentContext } from '../../app/components/componentContext/ComponentContext'; -import { isMainBranch } from '../../helpers/branch-like'; import { getHostUrl } from '../../helpers/urls'; import { hasGlobalPermission } from '../../helpers/users'; import { AlmSettingsInstance, ProjectAlmBindingResponse } from '../../types/alm-settings'; -import { MainBranch } from '../../types/branch-like'; import { Permissions } from '../../types/permissions'; import { SettingsKey } from '../../types/settings'; import { Component } from '../../types/types'; @@ -50,8 +47,6 @@ interface State { loading: boolean; } -const DEFAULT_MAIN_BRANCH_NAME = 'main'; - export class TutorialSelection extends React.PureComponent<Props, State> { mounted = false; state: State = { @@ -121,25 +116,17 @@ export class TutorialSelection extends React.PureComponent<Props, State> { const selectedTutorial: TutorialModes | undefined = location.query?.selectedTutorial; return ( - <ComponentContext.Consumer> - {({ branchLikes }) => ( - <TutorialSelectionRenderer - almBinding={almBinding} - baseUrl={baseUrl} - component={component} - currentUser={currentUser} - currentUserCanScanProject={currentUserCanScanProject} - loading={loading} - mainBranchName={ - (branchLikes.find((b) => isMainBranch(b)) as MainBranch | undefined)?.name || - DEFAULT_MAIN_BRANCH_NAME - } - projectBinding={projectBinding} - selectedTutorial={selectedTutorial} - willRefreshAutomatically={willRefreshAutomatically} - /> - )} - </ComponentContext.Consumer> + <TutorialSelectionRenderer + almBinding={almBinding} + baseUrl={baseUrl} + component={component} + currentUser={currentUser} + currentUserCanScanProject={currentUserCanScanProject} + loading={loading} + projectBinding={projectBinding} + selectedTutorial={selectedTutorial} + willRefreshAutomatically={willRefreshAutomatically} + /> ); } } 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 4bdf4962281..e3328ad6ef7 100644 --- a/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx +++ b/server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx @@ -28,10 +28,13 @@ import { Title, } from 'design-system'; import * as React from 'react'; +import { isMainBranch } from '../../helpers/branch-like'; import { translate } from '../../helpers/l10n'; import { getBaseUrl } from '../../helpers/system'; import { getProjectTutorialLocation, getProjectUrl } from '../../helpers/urls'; +import { useBranchesQuery } from '../../queries/branch'; import { AlmKeys, AlmSettingsInstance, ProjectAlmBindingResponse } from '../../types/alm-settings'; +import { MainBranch } from '../../types/branch-like'; import { Component } from '../../types/types'; import { LoggedInUser } from '../../types/users'; import { Alert } from '../ui/Alert'; @@ -43,6 +46,8 @@ import JenkinsTutorial from './jenkins/JenkinsTutorial'; import OtherTutorial from './other/OtherTutorial'; import { TutorialModes } from './types'; +const DEFAULT_MAIN_BRANCH_NAME = 'main'; + export interface TutorialSelectionRendererProps { almBinding?: AlmSettingsInstance; baseUrl: string; @@ -50,7 +55,6 @@ export interface TutorialSelectionRendererProps { currentUser: LoggedInUser; currentUserCanScanProject: boolean; loading: boolean; - mainBranchName: string; projectBinding?: ProjectAlmBindingResponse; selectedTutorial?: TutorialModes; willRefreshAutomatically?: boolean; @@ -85,11 +89,17 @@ export default function TutorialSelectionRenderer(props: TutorialSelectionRender currentUser, currentUserCanScanProject, loading, - mainBranchName, projectBinding, selectedTutorial, willRefreshAutomatically, } = props; + + const { data: { branchLikes } = { branchLikes: [] } } = useBranchesQuery(component); + + const mainBranchName = + (branchLikes.find((b) => isMainBranch(b)) as MainBranch | undefined)?.name || + DEFAULT_MAIN_BRANCH_NAME; + if (loading) { return <i aria-label={translate('loading')} className="spinner" />; } 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 a13a514b11a..d8404cc1e4b 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 @@ -25,24 +25,23 @@ import { getAlmSettingsNoCatch } from '../../../api/alm-settings'; import { getScannableProjects } from '../../../api/components'; import SettingsServiceMock from '../../../api/mocks/SettingsServiceMock'; import UserTokensMock from '../../../api/mocks/UserTokensMock'; -import { - mockGithubBindingDefinition, - mockProjectAlmBindingResponse, -} from '../../../helpers/mocks/alm-settings'; +import { mockProjectAlmBindingResponse } from '../../../helpers/mocks/alm-settings'; import { mockComponent } from '../../../helpers/mocks/component'; import { mockLoggedInUser } from '../../../helpers/testMocks'; import { renderApp } from '../../../helpers/testReactTestingUtils'; import { byLabelText, byRole, byText } from '../../../helpers/testSelector'; +import { ComponentPropsType } from '../../../helpers/testUtils'; import { AlmKeys } from '../../../types/alm-settings'; import { Feature } from '../../../types/features'; import { Permissions } from '../../../types/permissions'; import { SettingsKey } from '../../../types/settings'; -import { withRouter } from '../../hoc/withRouter'; -import { TutorialSelection } from '../TutorialSelection'; +import TutorialSelection from '../TutorialSelection'; import { TutorialModes } from '../types'; jest.mock('../../../api/user-tokens'); +jest.mock('../../../api/branches'); + jest.mock('../../../helpers/urls', () => ({ ...jest.requireActual('../../../helpers/urls'), getHostUrl: jest.fn().mockReturnValue('http://host.url'), @@ -120,9 +119,11 @@ it.each([ }); it('should correctly fetch the corresponding ALM setting', async () => { - (getAlmSettingsNoCatch as jest.Mock).mockResolvedValueOnce([ - mockGithubBindingDefinition({ key: 'binding', url: 'https://enterprise.github.com' }), - ]); + jest + .mocked(getAlmSettingsNoCatch) + .mockResolvedValueOnce([ + { key: 'binding', url: 'https://enterprise.github.com', alm: AlmKeys.GitHub }, + ]); const user = userEvent.setup(); renderTutorialSelection( { @@ -160,7 +161,9 @@ it('should fallback on the host URL', async () => { }); it('should not display a warning if the user has no global scan permission, but can scan the project', async () => { - (getScannableProjects as jest.Mock).mockResolvedValueOnce({ projects: [{ key: 'foo' }] }); + jest + .mocked(getScannableProjects) + .mockResolvedValueOnce({ projects: [{ key: 'foo', name: 'foo' }] }); renderTutorialSelection({ currentUser: mockLoggedInUser() }); await waitOnDataLoaded(); @@ -194,16 +197,12 @@ async function startJenkinsTutorial(user: UserEvent) { } function renderTutorialSelection( - props: Partial<TutorialSelection['props']> = {}, + props: Partial<ComponentPropsType<typeof TutorialSelection>> = {}, navigateTo: string = 'dashboard?id=bar' ) { - const Wrapper = withRouter(({ location, ...subProps }: TutorialSelection['props']) => { - return <TutorialSelection location={location} {...subProps} />; - }); - return renderApp( '/dashboard', - <Wrapper + <TutorialSelection component={mockComponent({ key: 'foo' })} currentUser={mockLoggedInUser({ permissions: { global: [Permissions.Scan] } })} {...props} diff --git a/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx b/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx index fe7a4e1a8cd..5688285e13b 100644 --- a/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx +++ b/server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx @@ -17,13 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { debounce } from 'lodash'; import * as React from 'react'; -import { getParents } from '../../api/components'; -import withBranchStatusActions from '../../app/components/branch-status/withBranchStatusActions'; -import { isPullRequest } from '../../helpers/branch-like'; -import { BranchLike } from '../../types/branch-like'; -import { Issue, SourceViewerFile } from '../../types/types'; +import { SourceViewerFile } from '../../types/types'; import SourceViewer from '../SourceViewer/SourceViewer'; import WorkspaceComponentTitle from './WorkspaceComponentTitle'; import WorkspaceHeader, { Props as WorkspaceHeaderProps } from './WorkspaceHeader'; @@ -31,20 +26,14 @@ import { ComponentDescriptor } from './context'; export interface Props extends Omit<WorkspaceHeaderProps, 'children' | 'onClose'> { component: ComponentDescriptor; - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => Promise<void>; height: number; onClose: (componentKey: string) => void; onLoad: (details: { key: string; name: string; qualifier: string }) => void; } -export class WorkspaceComponentViewer extends React.PureComponent<Props> { +export default class WorkspaceComponentViewer extends React.PureComponent<Props> { container?: HTMLElement | null; - constructor(props: Props) { - super(props); - this.refreshBranchStatus = debounce(this.refreshBranchStatus, 1000); - } - componentDidMount() { if (document.documentElement) { document.documentElement.classList.add('with-workspace'); @@ -61,10 +50,6 @@ export class WorkspaceComponentViewer extends React.PureComponent<Props> { this.props.onClose(this.props.component.key); }; - handleIssueChange = (_: Issue) => { - this.refreshBranchStatus(); - }; - handleLoaded = (component: SourceViewerFile) => { this.props.onLoad({ key: this.props.component.key, @@ -82,21 +67,6 @@ export class WorkspaceComponentViewer extends React.PureComponent<Props> { } }; - refreshBranchStatus = () => { - const { component } = this.props; - const { branchLike } = component; - if (branchLike && isPullRequest(branchLike)) { - getParents(component.key).then( - (parents?: any[]) => { - if (parents && parents.length > 0) { - this.props.fetchBranchStatus(branchLike, parents.pop().key); - } - }, - () => {} - ); - } - }; - render() { const { component } = this.props; @@ -123,7 +93,6 @@ export class WorkspaceComponentViewer extends React.PureComponent<Props> { branchLike={component.branchLike} component={component.key} highlightedLine={component.line} - onIssueChange={this.handleIssueChange} onLoaded={this.handleLoaded} /> </div> @@ -131,5 +100,3 @@ export class WorkspaceComponentViewer extends React.PureComponent<Props> { ); } } - -export default withBranchStatusActions(WorkspaceComponentViewer); diff --git a/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceComponentViewer-test.tsx b/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceComponentViewer-test.tsx index f278e12fb9a..5efd0bdb0e6 100644 --- a/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceComponentViewer-test.tsx +++ b/server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceComponentViewer-test.tsx @@ -19,11 +19,7 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import { getParents } from '../../../api/components'; -import { mockPullRequest } from '../../../helpers/mocks/branch-like'; -import { mockIssue } from '../../../helpers/testMocks'; -import { waitAndUpdate } from '../../../helpers/testUtils'; -import { Props, WorkspaceComponentViewer } from '../WorkspaceComponentViewer'; +import WorkspaceComponentViewer, { Props } from '../WorkspaceComponentViewer'; jest.mock('../../../api/components', () => ({ getParents: jest.fn().mockResolvedValue([{ key: 'bar' }]), @@ -55,28 +51,10 @@ it('should call back after load', () => { expect(onLoad).toHaveBeenCalledWith({ key: 'foo', name: 'src/foo.js', qualifier: 'FIL' }); }); -it('should refresh branch status if issues are updated', async () => { - const fetchBranchStatus = jest.fn(); - const branchLike = mockPullRequest(); - const component = { - branchLike, - key: 'foo', - }; - const wrapper = shallowRender({ component, fetchBranchStatus }); - const instance = wrapper.instance(); - await waitAndUpdate(wrapper); - - instance.handleIssueChange(mockIssue()); - expect(getParents).toHaveBeenCalledWith(component.key); - await waitAndUpdate(wrapper); - expect(fetchBranchStatus).toHaveBeenCalledWith(branchLike, 'bar'); -}); - function shallowRender(props?: Partial<Props>) { return shallow<WorkspaceComponentViewer>( <WorkspaceComponentViewer component={{ branchLike: undefined, key: 'foo' }} - fetchBranchStatus={jest.fn()} height={300} onClose={jest.fn()} onCollapse={jest.fn()} diff --git a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/Workspace-test.tsx.snap b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/Workspace-test.tsx.snap index e7ef8687609..8a4dba80db1 100644 --- a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/Workspace-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/Workspace-test.tsx.snap @@ -53,7 +53,7 @@ exports[`should render correctly: open component 1`] = ` } } /> - <withBranchStatusActions(WorkspaceComponentViewer) + <WorkspaceComponentViewer component={ { "branchLike": { diff --git a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceComponentViewer-test.tsx.snap b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceComponentViewer-test.tsx.snap index c1190c2ad02..65ba4da4254 100644 --- a/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceComponentViewer-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceComponentViewer-test.tsx.snap @@ -34,7 +34,6 @@ exports[`should render 1`] = ` displayIssueLocationsCount={true} displayIssueLocationsLink={true} displayLocationMarkers={true} - onIssueChange={[Function]} onLoaded={[Function]} /> </div> diff --git a/server/sonar-web/src/main/js/helpers/branch-like.ts b/server/sonar-web/src/main/js/helpers/branch-like.ts index 1b8f75fcb6d..f5c6f5d5f51 100644 --- a/server/sonar-web/src/main/js/helpers/branch-like.ts +++ b/server/sonar-web/src/main/js/helpers/branch-like.ts @@ -23,11 +23,9 @@ import { BranchLike, BranchLikeTree, BranchParameters, - BranchStatusData, MainBranch, PullRequest, } from '../types/branch-like'; -import { Dict } from '../types/types'; export function isBranch(branchLike?: BranchLike): branchLike is Branch { return branchLike !== undefined && (branchLike as Branch).isMain !== undefined; @@ -139,12 +137,3 @@ export function fillBranchLike( } return undefined; } - -export function getBranchStatusByBranchLike( - branchStatusByComponent: Dict<Dict<BranchStatusData>>, - component: string, - branchLike: BranchLike -): BranchStatusData { - const branchLikeKey = getBranchLikeKey(branchLike); - return branchStatusByComponent[component] && branchStatusByComponent[component][branchLikeKey]; -} diff --git a/server/sonar-web/src/main/js/helpers/mocks/quality-gates.ts b/server/sonar-web/src/main/js/helpers/mocks/quality-gates.ts index 2a4a246f406..55c791d724a 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/quality-gates.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/quality-gates.ts @@ -20,6 +20,7 @@ import { QualityGateApplicationStatus, QualityGateProjectStatus, + QualityGateProjectStatusCondition, QualityGateStatus, QualityGateStatusCondition, QualityGateStatusConditionEnhanced, @@ -48,6 +49,20 @@ export function mockQualityGateStatus( }; } +export function mockQualityGateProjectCondition( + overrides: Partial<QualityGateProjectStatusCondition> = {} +): QualityGateProjectStatusCondition { + return { + actualValue: '10', + errorThreshold: '0', + status: 'ERROR', + metricKey: 'foo', + comparator: 'GT', + periodIndex: 1, + ...overrides, + }; +} + export function mockQualityGateStatusCondition( overrides: Partial<QualityGateStatusCondition> = {} ): QualityGateStatusCondition { diff --git a/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx index 2ef2063a9ea..3b8fcced33e 100644 --- a/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx +++ b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx @@ -94,7 +94,7 @@ export function renderAppWithAdminContext( export function renderComponent( component: React.ReactElement, pathname = '/', - { appState = mockAppState() }: RenderContext = {} + { appState = mockAppState(), featureList = [] }: RenderContext = {} ) { function Wrapper({ children }: { children: React.ReactElement }) { const queryClient = new QueryClient(); @@ -103,13 +103,15 @@ export function renderComponent( <IntlProvider defaultLocale="en" locale="en"> <QueryClientProvider client={queryClient}> <HelmetProvider> - <AppStateContextProvider appState={appState}> - <MemoryRouter initialEntries={[pathname]}> - <Routes> - <Route path="*" element={children} /> - </Routes> - </MemoryRouter> - </AppStateContextProvider> + <AvailableFeaturesContext.Provider value={featureList}> + <AppStateContextProvider appState={appState}> + <MemoryRouter initialEntries={[pathname]}> + <Routes> + <Route path="*" element={children} /> + </Routes> + </MemoryRouter> + </AppStateContextProvider> + </AvailableFeaturesContext.Provider> </HelmetProvider> </QueryClientProvider> </IntlProvider> @@ -132,8 +134,6 @@ export function renderAppWithComponentContext( return ( <ComponentContext.Provider value={{ - branchLikes: [], - onBranchesChange: jest.fn(), onComponentChange: (changes: Partial<Component>) => { setRealComponent({ ...realComponent, ...changes }); }, diff --git a/server/sonar-web/src/main/js/helpers/testSelector.ts b/server/sonar-web/src/main/js/helpers/testSelector.ts index 5a7a99f8e66..0f6eae1adb8 100644 --- a/server/sonar-web/src/main/js/helpers/testSelector.ts +++ b/server/sonar-web/src/main/js/helpers/testSelector.ts @@ -52,6 +52,15 @@ export interface ReactTestingQuery { byLabelText(...args: Parameters<BoundFunction<GetByText>>): ReactTestingQuery; byTestId(...args: Parameters<BoundFunction<GetByBoundAttribute>>): ReactTestingQuery; byDisplayValue(...args: Parameters<BoundFunction<GetByBoundAttribute>>): ReactTestingQuery; + + getAt<T extends HTMLElement = HTMLElement>(index: number, container?: HTMLElement): T; + findAt<T extends HTMLElement = HTMLElement>( + index: number, + container?: HTMLElement, + waitForOptions?: waitForOptions + ): Promise<T>; + + queryAt<T extends HTMLElement = HTMLElement>(index: number, container?: HTMLElement): T | null; } abstract class ChainingQuery implements ReactTestingQuery { @@ -73,6 +82,26 @@ abstract class ChainingQuery implements ReactTestingQuery { abstract queryAll<T extends HTMLElement = HTMLElement>(container?: HTMLElement): T[] | null; + getAt<T extends HTMLElement = HTMLElement>(index: number, container?: HTMLElement): T { + return this.getAll<T>(container)[index]; + } + + async findAt<T extends HTMLElement = HTMLElement>( + index: number, + container?: HTMLElement, + waitForOptions?: waitForOptions + ): Promise<T> { + return (await this.findAll<T>(container, waitForOptions))[index]; + } + + queryAt<T extends HTMLElement = HTMLElement>(index: number, container?: HTMLElement): T | null { + const all = this.queryAll<T>(container); + if (all) { + return all[index]; + } + return null; + } + byText(...args: Parameters<BoundFunction<GetByText>>): ReactTestingQuery { return new ChainDispatch(this, new DispatchByText(args)); } diff --git a/server/sonar-web/src/main/js/helpers/testUtils.ts b/server/sonar-web/src/main/js/helpers/testUtils.ts index 2a7e904c232..3506f663e3c 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.ts +++ b/server/sonar-web/src/main/js/helpers/testUtils.ts @@ -18,10 +18,12 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { ReactWrapper, ShallowWrapper } from 'enzyme'; +import { ComponentClass, FunctionComponent } from 'react'; import { setImmediate } from 'timers'; import { KeyboardKeys } from './keycodes'; -export type FCProps<T extends React.FunctionComponent<any>> = Parameters<T>[0]; +export type ComponentPropsType<T extends ComponentClass | FunctionComponent<any>> = + T extends ComponentClass<infer P> ? P : T extends FunctionComponent<infer P> ? P : never; export function mockEvent(overrides = {}) { return { diff --git a/server/sonar-web/src/main/js/helpers/urls.ts b/server/sonar-web/src/main/js/helpers/urls.ts index a5a36882791..e00acea704f 100644 --- a/server/sonar-web/src/main/js/helpers/urls.ts +++ b/server/sonar-web/src/main/js/helpers/urls.ts @@ -451,10 +451,13 @@ export function isRelativeUrl(url?: string): boolean { return Boolean(url && regex.test(url)); } -export function searchParamsToQuery(searchParams: URLSearchParams) { +export function searchParamsToQuery(searchParams: URLSearchParams, omitKey: string[] = []) { const result: RawQuery = {}; searchParams.forEach((value, key) => { + if (omitKey.includes(key)) { + return; + } if (result[key]) { result[key] = ([] as string[]).concat(result[key], value); } else { diff --git a/server/sonar-web/src/main/js/queries/branch.tsx b/server/sonar-web/src/main/js/queries/branch.tsx new file mode 100644 index 00000000000..1d99799b290 --- /dev/null +++ b/server/sonar-web/src/main/js/queries/branch.tsx @@ -0,0 +1,333 @@ +/* + * 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { debounce, flatten } from 'lodash'; +import * as React from 'react'; +import { useCallback, useContext } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + deleteBranch, + deletePullRequest, + excludeBranchFromPurge, + getBranches, + getPullRequests, + renameBranch, +} from '../api/branches'; +import { dismissAnalysisWarning, getAnalysisStatus } from '../api/ce'; +import { getQualityGateProjectStatus } from '../api/quality-gates'; +import { AvailableFeaturesContext } from '../app/components/available-features/AvailableFeaturesContext'; +import { useLocation } from '../components/hoc/withRouter'; +import { isBranch, isPullRequest } from '../helpers/branch-like'; +import { extractStatusConditionsFromProjectStatus } from '../helpers/qualityGates'; +import { searchParamsToQuery } from '../helpers/urls'; +import { BranchLike } from '../types/branch-like'; +import { isApplication, isPortfolioLike, isProject } from '../types/component'; +import { Feature } from '../types/features'; +import { Component } from '../types/types'; + +// This will prevent refresh when navigating from page to page. +const BRANCHES_STALE_TIME = 30_000; + +enum InnerState { + Details = 'details', + Warning = 'warning', + Status = 'status', +} + +function useBranchesQueryKey(innerState: InnerState) { + // Currently, we do not have the component in a react-state ready + // Once we refactor we will be able to fetch it from query state. + // We will be able to make sure that the component is not a portfolio. + // Mixing query param and react-state is dangerous. + // It should be avoided as much as possible. + const { search } = useLocation(); + const searchParams = new URLSearchParams(search); + + if (searchParams.has('pullRequest') && searchParams.has('id')) { + return [ + 'branches', + searchParams.get('id') as string, + 'pull-request', + searchParams.get('pullRequest') as string, + innerState, + ] as const; + } else if (searchParams.has('branch') && searchParams.has('id')) { + return [ + 'branches', + searchParams.get('id') as string, + 'branch', + searchParams.get('branch') as string, + innerState, + ] as const; + } else if (searchParams.has('id')) { + return ['branches', searchParams.get('id') as string, innerState] as const; + } + return ['branches']; +} + +function useMutateBranchQueryKey() { + const { search } = useLocation(); + const searchParams = new URLSearchParams(search); + + if (searchParams.has('id')) { + return ['branches', searchParams.get('id') as string] as const; + } + return ['branches']; +} + +function getContext(key: ReturnType<typeof useBranchesQueryKey>) { + const [_b, componentKey, prOrBranch, branchKey] = key; + if (prOrBranch === 'pull-request') { + return { componentKey, query: { pullRequest: branchKey } }; + } + if (prOrBranch === 'branch') { + return { componentKey, query: { branch: branchKey } }; + } + return { componentKey, query: {} }; +} + +export function useBranchesQuery(component?: Component) { + const features = useContext(AvailableFeaturesContext); + const key = useBranchesQueryKey(InnerState.Details); + return useQuery({ + queryKey: key, + queryFn: async ({ queryKey: [_, key, prOrBranch, name] }) => { + if (component === undefined || key === undefined) { + return { branchLikes: [] }; + } + if (isPortfolioLike(component.qualifier)) { + return { branchLikes: [] }; + } + + const branchLikesPromise = + isProject(component.qualifier) && features.includes(Feature.BranchSupport) + ? [getBranches(key), getPullRequests(key)] + : [getBranches(key)]; + const branchLikes = await Promise.all(branchLikesPromise).then(flatten<BranchLike>); + const branchLike = + prOrBranch === 'pull-request' + ? branchLikes.find((b) => isPullRequest(b) && b.key === name) + : branchLikes.find( + (b) => isBranch(b) && (prOrBranch === 'branch' ? b.name === name : b.isMain) + ); + return { branchLikes, branchLike }; + }, + // The check of the key must desapear once component state is in react-query + enabled: !!component && component.key === key[1], + staleTime: BRANCHES_STALE_TIME, + }); +} + +export function useBranchStatusQuery(component: Component) { + const key = useBranchesQueryKey(InnerState.Status); + return useQuery({ + queryKey: key, + queryFn: async ({ queryKey }) => { + const { query } = getContext(queryKey); + if (!isProject(component.qualifier)) { + return {}; + } + const projectStatus = await getQualityGateProjectStatus({ + projectKey: component.key, + ...query, + }).catch(() => undefined); + if (projectStatus === undefined) { + return {}; + } + + const { ignoredConditions, status } = projectStatus; + const conditions = extractStatusConditionsFromProjectStatus(projectStatus); + return { + conditions, + ignoredConditions, + status, + }; + }, + enabled: isProject(component.qualifier) || isApplication(component.qualifier), + staleTime: BRANCHES_STALE_TIME, + }); +} + +export function useBranchWarrningQuery(component: Component) { + const branchQuery = useBranchesQuery(component); + const branchLike = branchQuery.data?.branchLike; + return useQuery({ + queryKey: useBranchesQueryKey(InnerState.Warning), + queryFn: async ({ queryKey }) => { + const { query, componentKey } = getContext(queryKey); + const { component: branchStatus } = await getAnalysisStatus({ + component: componentKey, + ...query, + }); + return branchStatus.warnings; + }, + enabled: !!branchLike && isProject(component.qualifier), + staleTime: BRANCHES_STALE_TIME, + }); +} + +export function useDismissBranchWarningMutation() { + type DismissArg = { component: Component; key: string }; + const queryClient = useQueryClient(); + const invalidateKey = useBranchesQueryKey(InnerState.Warning); + + return useMutation({ + mutationFn: async ({ component, key }: DismissArg) => { + await dismissAnalysisWarning(component.key, key); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: invalidateKey }); + }, + }); +} + +export function useExcludeFromPurgeMutation() { + const queryClient = useQueryClient(); + const invalidateKey = useMutateBranchQueryKey(); + + type ExcludeFromPurgeArg = { component: Component; key: string; exclude: boolean }; + + return useMutation({ + mutationFn: async ({ component, key, exclude }: ExcludeFromPurgeArg) => { + await excludeBranchFromPurge(component.key, key, exclude); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: invalidateKey }); + }, + }); +} + +export function useDeletBranchMutation() { + type DeleteArg = { branchLike: BranchLike; component: Component }; + const queryClient = useQueryClient(); + const [params, setSearhParam] = useSearchParams(); + const invalidateKey = useMutateBranchQueryKey(); + + return useMutation({ + mutationFn: async ({ branchLike, component }: DeleteArg) => { + await (isPullRequest(branchLike) + ? deletePullRequest({ + project: component.key, + pullRequest: branchLike.key, + }) + : deleteBranch({ + branch: branchLike.name, + project: component.key, + })); + + if ( + isBranch(branchLike) && + params.has('branch') && + params.get('branch') === branchLike.name + ) { + setSearhParam(searchParamsToQuery(params, ['branch'])); + return { navigate: true }; + } + + if ( + isPullRequest(branchLike) && + params.has('pullRequest') && + params.get('pullRequest') === branchLike.key + ) { + setSearhParam(searchParamsToQuery(params, ['pullRequest'])); + return { navigate: true }; + } + return { navigate: false }; + }, + onSuccess({ navigate }) { + if (!navigate) { + queryClient.invalidateQueries({ queryKey: invalidateKey }); + } + }, + }); +} + +export function useRenameMainBranchMutation() { + type RenameMainBranchArg = { name: string; component: Component }; + const queryClient = useQueryClient(); + const invalidateKey = useMutateBranchQueryKey(); + + return useMutation({ + mutationFn: async ({ component, name }: RenameMainBranchArg) => { + await renameBranch(component.key, name); + }, + onSuccess() { + queryClient.invalidateQueries({ queryKey: invalidateKey }); + }, + }); +} + +/** + * Helper functions that sould be avoid. Instead convert the component into functional + * and/or use proper react-query + */ +const DELAY_REFRECH = 1_000; + +export function useRefreshBranchStatus(): () => void { + const queryClient = useQueryClient(); + const invalidateStatusKey = useBranchesQueryKey(InnerState.Status); + const invalidateDetailsKey = useBranchesQueryKey(InnerState.Details); + + return useCallback( + debounce(() => { + queryClient.invalidateQueries({ + queryKey: invalidateStatusKey, + }); + queryClient.invalidateQueries({ + queryKey: invalidateDetailsKey, + }); + }, DELAY_REFRECH), + [invalidateDetailsKey, invalidateStatusKey] + ); +} + +export function useRefreshBranches() { + const queryClient = useQueryClient(); + const invalidateKey = useMutateBranchQueryKey(); + + return () => { + queryClient.invalidateQueries({ queryKey: invalidateKey }); + }; +} + +export function withBranchLikes<P extends { component?: Component }>( + WrappedComponent: React.ComponentType<P & { branchLikes?: BranchLike[]; branchLike?: BranchLike }> +): React.ComponentType<Omit<P, 'branchLike' | 'branchLikes'>> { + return function WithBranchLike(p: P) { + const { data } = useBranchesQuery(p.component); + return ( + <WrappedComponent + branchLikes={data?.branchLikes ?? []} + branchLike={data?.branchLike} + {...p} + /> + ); + }; +} + +export function withBranchStatusRefresh< + P extends { refreshBranchStatus: ReturnType<typeof useRefreshBranchStatus> } +>(WrappedComponent: React.ComponentType<P>): React.ComponentType<Omit<P, 'refreshBranchStatus'>> { + return function WithBranchStatusRefresh(props: P) { + const refresh = useRefreshBranchStatus(); + + return <WrappedComponent {...props} refreshBranchStatus={refresh} />; + }; +} diff --git a/server/sonar-web/src/main/js/app/components/branch-status/BranchStatusContext.tsx b/server/sonar-web/src/main/js/queries/withQueryClientHoc.tsx index 57c4ee1f654..21beffc5ec7 100644 --- a/server/sonar-web/src/main/js/app/components/branch-status/BranchStatusContext.tsx +++ b/server/sonar-web/src/main/js/queries/withQueryClientHoc.tsx @@ -17,29 +17,17 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { QueryClient, useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; -import { BranchLike, BranchStatusData } from '../../../types/branch-like'; -import { QualityGateStatusCondition } from '../../../types/quality-gates'; -import { Dict, Status } from '../../../types/types'; +import { ComponentClass, VFC } from 'react'; -export interface BranchStatusContextInterface { - branchStatusByComponent: Dict<Dict<BranchStatusData>>; - fetchBranchStatus: (branchLike: BranchLike, projectKey: string) => void; - updateBranchStatus: ( - branchLike: BranchLike, - projectKey: string, - status: Status, - conditions?: QualityGateStatusCondition[], - ignoredConditions?: boolean - ) => void; +export function withQueryClient<P>( + Component: + | ComponentClass<P & { queryClient: QueryClient }> + | VFC<P & { queryClient: QueryClient }> +): VFC<Omit<P, 'queryClient'>> { + return function WithQueryClient(props: P) { + const queryClient = useQueryClient(); + return <Component {...props} queryClient={queryClient} />; + }; } - -export const BranchStatusContext = React.createContext<BranchStatusContextInterface>({ - branchStatusByComponent: {}, - fetchBranchStatus: () => { - throw Error('BranchStatusContext is not provided'); - }, - updateBranchStatus: () => { - throw Error('BranchStatusContext is not provided'); - }, -}); diff --git a/server/sonar-web/src/main/js/types/__tests__/__snapshots__/component-test.ts.snap b/server/sonar-web/src/main/js/types/__tests__/__snapshots__/component-test.ts.snap index ab5a3b6fd8e..11feb73a504 100644 --- a/server/sonar-web/src/main/js/types/__tests__/__snapshots__/component-test.ts.snap +++ b/server/sonar-web/src/main/js/types/__tests__/__snapshots__/component-test.ts.snap @@ -3,7 +3,6 @@ exports[`[Function isApplication] should work properly 1`] = ` { "APP": true, - "DEV": false, "DIR": false, "FIL": false, "SVW": false, @@ -16,7 +15,6 @@ exports[`[Function isApplication] should work properly 1`] = ` exports[`[Function isFile] should work properly 1`] = ` { "APP": false, - "DEV": false, "DIR": false, "FIL": true, "SVW": false, @@ -29,7 +27,6 @@ exports[`[Function isFile] should work properly 1`] = ` exports[`[Function isPortfolioLike] should work properly 1`] = ` { "APP": false, - "DEV": false, "DIR": false, "FIL": false, "SVW": true, @@ -42,7 +39,6 @@ exports[`[Function isPortfolioLike] should work properly 1`] = ` exports[`[Function isProject] should work properly 1`] = ` { "APP": false, - "DEV": false, "DIR": false, "FIL": false, "SVW": false, @@ -55,7 +51,6 @@ exports[`[Function isProject] should work properly 1`] = ` exports[`[Function isView] should work properly 1`] = ` { "APP": true, - "DEV": false, "DIR": false, "FIL": false, "SVW": true, diff --git a/server/sonar-web/src/main/js/types/component.ts b/server/sonar-web/src/main/js/types/component.ts index e0b98f13d3e..4e7a678cc5c 100644 --- a/server/sonar-web/src/main/js/types/component.ts +++ b/server/sonar-web/src/main/js/types/component.ts @@ -18,7 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { ProjectAlmBindingResponse } from './alm-settings'; -import { BranchLike } from './branch-like'; import { Component, LightComponent } from './types'; export enum Visibility { @@ -29,7 +28,6 @@ export enum Visibility { export enum ComponentQualifier { Application = 'APP', Directory = 'DIR', - Developper = 'DEV', File = 'FIL', Portfolio = 'VW', Project = 'TRK', @@ -98,12 +96,9 @@ export function isView( } export interface ComponentContextShape { - branchLike?: BranchLike; - branchLikes: BranchLike[]; component?: Component; isInProgress?: boolean; isPending?: boolean; - onBranchesChange: (updateBranches?: boolean, updatePRs?: boolean) => void; onComponentChange: (changes: Partial<Component>) => void; projectBinding?: ProjectAlmBindingResponse; } diff --git a/server/sonar-web/src/main/js/types/extension.ts b/server/sonar-web/src/main/js/types/extension.ts index 1f70b29f9cc..efcf9622b45 100644 --- a/server/sonar-web/src/main/js/types/extension.ts +++ b/server/sonar-web/src/main/js/types/extension.ts @@ -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 { QueryClient } from '@tanstack/react-query'; import { Theme } from 'design-system'; import { IntlShape } from 'react-intl'; import { Location, Router } from '../components/hoc/withRouter'; @@ -59,6 +60,7 @@ export interface ExtensionStartMethodParameter { dsTheme: Theme; baseUrl: string; l10nBundle: L10nBundle; + queryClient: QueryClient; // See SONAR-16207 and core-extension-enterprise-server/src/main/js/portfolios/components/Header.tsx // for more information on why we're passing this as a prop to an extension. updateCurrentUserHomepage: (homepage: HomePage) => void; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 79aa25884c0..41b5a82ee18 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -412,7 +412,6 @@ qualifier.APP=Application qualifier.FIL=File qualifier.CLA=File qualifier.UTS=Test File -qualifier.DEV=Developer qualifier.configuration.TRK=Project Configuration qualifier.configuration.VW=Portfolio Configuration @@ -428,16 +427,13 @@ qualifiers.APP=Applications qualifiers.FIL=Files qualifiers.CLA=Files qualifiers.UTS=Test Files -qualifiers.DEV=Developers qualifiers.all.TRK=All Projects qualifiers.all.VW=All Portfolios -qualifiers.all.DEV=All Developers qualifiers.all.APP=All Applications qualifiers.new.TRK=New Project qualifiers.new.VW=New Portfolio -qualifiers.new.DEV=New Developer qualifiers.new.APP=New Application qualifier.delete.TRK=Delete Project @@ -458,11 +454,9 @@ qualifiers.delete_confirm.APP=Do you want to delete these applications? qualifiers.create.TRK=Create Project qualifiers.create.VW=Create Portfolio -qualifiers.create.DEV=Create Developer qualifiers.create.APP=Create Application qualifiers.update.VW=Update Portfolio -qualifiers.update.DEV=Update Developer qualifiers.update.APP=Update Application qualifier.description.VW=Potentially multi-level, management-oriented overview aggregation. |