Browse Source

SONAR-19840 Move branch[es], branch-status, branch-warning state to react-query

tags/10.2.0.77647
Mathieu Suen 11 months ago
parent
commit
3c41d8d0f2
100 changed files with 1472 additions and 2250 deletions
  1. 10
    1
      server/sonar-web/src/main/js/api/mocks/BranchesServiceMock.ts
  2. 2
    2
      server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts
  3. 26
    153
      server/sonar-web/src/main/js/app/components/ComponentContainer.tsx
  4. 13
    16
      server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
  5. 16
    72
      server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx
  6. 0
    104
      server/sonar-web/src/main/js/app/components/branch-status/BranchStatusContextProvider.tsx
  7. 0
    73
      server/sonar-web/src/main/js/app/components/branch-status/__tests__/BranchStatusContextProvider-test.tsx
  8. 0
    58
      server/sonar-web/src/main/js/app/components/branch-status/withBranchStatus.tsx
  9. 0
    50
      server/sonar-web/src/main/js/app/components/branch-status/withBranchStatusActions.tsx
  10. 0
    2
      server/sonar-web/src/main/js/app/components/componentContext/ComponentContext.ts
  11. 1
    1
      server/sonar-web/src/main/js/app/components/current-user/withCurrentUserContext.tsx
  12. 6
    2
      server/sonar-web/src/main/js/app/components/extensions/Extension.tsx
  13. 6
    2
      server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx
  14. 5
    2
      server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx
  15. 15
    11
      server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.tsx
  16. 35
    24
      server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectPageExtension-test.tsx
  17. 0
    1
      server/sonar-web/src/main/js/app/components/global-search/utils.ts
  18. 21
    2
      server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorMessage.tsx
  19. 1
    3
      server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorModal.tsx
  20. 7
    16
      server/sonar-web/src/main/js/app/components/nav/component/AnalysisStatus.tsx
  21. 103
    0
      server/sonar-web/src/main/js/app/components/nav/component/AnalysisWarningsModal.tsx
  22. 5
    43
      server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx
  23. 4
    16
      server/sonar-web/src/main/js/app/components/nav/component/Header.tsx
  24. 5
    19
      server/sonar-web/src/main/js/app/components/nav/component/HeaderMeta.tsx
  25. 130
    186
      server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx
  26. 22
    11
      server/sonar-web/src/main/js/app/components/nav/component/__tests__/AnalysisErrorMessage-test.tsx
  27. 1
    12
      server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx
  28. 70
    78
      server/sonar-web/src/main/js/app/components/nav/component/__tests__/Header-test.tsx
  29. 43
    30
      server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx
  30. 33
    30
      server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx
  31. 62
    58
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx
  32. 2
    4
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx
  33. 0
    1
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/Menu.tsx
  34. 1
    4
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItem.tsx
  35. 1
    4
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItemList.tsx
  36. 12
    23
      server/sonar-web/src/main/js/app/components/nav/component/branch-like/QualityGateStatus.tsx
  37. 1
    3
      server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx
  38. 3
    2
      server/sonar-web/src/main/js/app/components/promotion-notification/__tests__/PromotionNotification-test.tsx
  39. 12
    23
      server/sonar-web/src/main/js/apps/background-tasks/components/AnalysisWarningsModal.tsx
  40. 1
    1
      server/sonar-web/src/main/js/apps/background-tasks/components/TaskActions.tsx
  41. 4
    21
      server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx
  42. 1
    3
      server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx
  43. 2
    4
      server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx
  44. 7
    6
      server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx
  45. 6
    27
      server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx
  46. 0
    3
      server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx
  47. 1
    8
      server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx
  48. 0
    3
      server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx
  49. 6
    1
      server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
  50. 4
    25
      server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
  51. 2
    1
      server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx
  52. 3
    3
      server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx
  53. 1
    2
      server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx
  54. 7
    0
      server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx
  55. 6
    5
      server/sonar-web/src/main/js/apps/overview/components/App.tsx
  56. 13
    11
      server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx
  57. 119
    185
      server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx
  58. 49
    23
      server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx
  59. 6
    0
      server/sonar-web/src/main/js/apps/projectBaseline/components/BranchList.tsx
  60. 6
    3
      server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineApp.tsx
  61. 5
    1
      server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx
  62. 29
    18
      server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx
  63. 2
    9
      server/sonar-web/src/main/js/apps/projectBranches/ProjectBranchesApp.tsx
  64. 69
    101
      server/sonar-web/src/main/js/apps/projectBranches/__tests__/ProjectBranchesApp-it.tsx
  65. 2
    7
      server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeRow.tsx
  66. 0
    2
      server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTable.tsx
  67. 49
    85
      server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTabs.tsx
  68. 24
    68
      server/sonar-web/src/main/js/apps/projectBranches/components/BranchPurgeSetting.tsx
  69. 39
    72
      server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx
  70. 48
    75
      server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx
  71. 2
    2
      server/sonar-web/src/main/js/apps/projects/filters/__tests__/CoverageFilter-test.tsx
  72. 2
    2
      server/sonar-web/src/main/js/apps/projects/filters/__tests__/LanguagesFilter-test.tsx
  73. 2
    2
      server/sonar-web/src/main/js/apps/projects/filters/__tests__/QualityGateFilter-test.tsx
  74. 4
    13
      server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx
  75. 10
    10
      server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx
  76. 8
    8
      server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx
  77. 0
    4
      server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx
  78. 0
    2
      server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx
  79. 4
    7
      server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx
  80. 2
    2
      server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx
  81. 21
    17
      server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx
  82. 8
    9
      server/sonar-web/src/main/js/components/common/BranchStatus.tsx
  83. 8
    21
      server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx
  84. 26
    16
      server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx
  85. 0
    15
      server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap
  86. 4
    4
      server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx
  87. 83
    76
      server/sonar-web/src/main/js/components/issue/Issue.tsx
  88. 7
    2
      server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx
  89. 11
    24
      server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx
  90. 12
    2
      server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx
  91. 15
    16
      server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx
  92. 2
    35
      server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx
  93. 1
    23
      server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceComponentViewer-test.tsx
  94. 1
    1
      server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/Workspace-test.tsx.snap
  95. 0
    1
      server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceComponentViewer-test.tsx.snap
  96. 0
    11
      server/sonar-web/src/main/js/helpers/branch-like.ts
  97. 15
    0
      server/sonar-web/src/main/js/helpers/mocks/quality-gates.ts
  98. 10
    10
      server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx
  99. 29
    0
      server/sonar-web/src/main/js/helpers/testSelector.ts
  100. 0
    0
      server/sonar-web/src/main/js/helpers/testUtils.ts

+ 10
- 1
server/sonar-web/src/main/js/api/mocks/BranchesServiceMock.ts View File

@@ -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);

+ 2
- 2
server/sonar-web/src/main/js/api/mocks/SecurityHotspotServiceMock.ts View File

@@ -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: [

+ 26
- 153
server/sonar-web/src/main/js/app/components/ComponentContainer.tsx View File

@@ -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));

+ 13
- 16
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx View File

@@ -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>

+ 16
- 72
server/sonar-web/src/main/js/app/components/__tests__/ComponentContainer-test.tsx View File

@@ -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}
>

+ 0
- 104
server/sonar-web/src/main/js/app/components/branch-status/BranchStatusContextProvider.tsx View File

@@ -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>
);
}
}

+ 0
- 73
server/sonar-web/src/main/js/app/components/branch-status/__tests__/BranchStatusContextProvider-test.tsx View File

@@ -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>
);
}

+ 0
- 58
server/sonar-web/src/main/js/app/components/branch-status/withBranchStatus.tsx View File

@@ -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>
);
}
};
}

+ 0
- 50
server/sonar-web/src/main/js/app/components/branch-status/withBranchStatusActions.tsx View File

@@ -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>
);
}
};
}

+ 0
- 2
server/sonar-web/src/main/js/app/components/componentContext/ComponentContext.ts View File

@@ -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,
});

+ 1
- 1
server/sonar-web/src/main/js/app/components/current-user/withCurrentUserContext.tsx View File

@@ -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');


+ 6
- 2
server/sonar-web/src/main/js/app/components/extensions/Extension.tsx View File

@@ -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)))))
);

+ 6
- 2
server/sonar-web/src/main/js/app/components/extensions/ProjectAdminPageExtension.tsx View File

@@ -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} />
);

+ 5
- 2
server/sonar-web/src/main/js/app/components/extensions/ProjectPageExtension.tsx View File

@@ -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}`

+ 15
- 11
server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectAdminPageExtension-test.tsx View File

@@ -17,6 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { 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>
);
}

+ 35
- 24
server/sonar-web/src/main/js/app/components/extensions/__tests__/ProjectPageExtension-test.tsx View File

@@ -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>
);
}

+ 0
- 1
server/sonar-web/src/main/js/app/components/global-search/utils.ts View File

@@ -21,7 +21,6 @@ import { sortBy } from 'lodash';
import { ComponentQualifier } from '../../../../js/types/component';

const ORDER = [
ComponentQualifier.Developper,
ComponentQualifier.Portfolio,
ComponentQualifier.SubPortfolio,
ComponentQualifier.Application,

+ 21
- 2
server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorMessage.tsx View File

@@ -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();


+ 1
- 3
server/sonar-web/src/main/js/app/components/nav/component/AnalysisErrorModal.tsx View File

@@ -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}
/>
)}

+ 7
- 16
server/sonar-web/src/main/js/app/components/nav/component/AnalysisStatus.tsx View File

@@ -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} />
)}
</>
);

+ 103
- 0
server/sonar-web/src/main/js/app/components/nav/component/AnalysisWarningsModal.tsx View File

@@ -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);

+ 5
- 43
server/sonar-web/src/main/js/app/components/nav/component/ComponentNav.tsx View File

@@ -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}
</>

+ 4
- 16
server/sonar-web/src/main/js/app/components/nav/component/Header.tsx View File

@@ -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>
);
}

+ 5
- 19
server/sonar-web/src/main/js/app/components/nav/component/HeaderMeta.tsx View File

@@ -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 && (

+ 130
- 186
server/sonar-web/src/main/js/app/components/nav/component/Menu.tsx View File

@@ -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);

+ 22
- 11
server/sonar-web/src/main/js/app/components/nav/component/__tests__/AnalysisErrorMessage-test.tsx View File

@@ -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] }
);
}

+ 1
- 12
server/sonar-web/src/main/js/app/components/nav/component/__tests__/ComponentNav-test.tsx View File

@@ -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}
/>
);

+ 70
- 78
server/sonar-web/src/main/js/app/components/nav/component/__tests__/Header-test.tsx View File

@@ -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' }
);
}

+ 43
- 30
server/sonar-web/src/main/js/app/components/nav/component/__tests__/HeaderMeta-test.tsx View File

@@ -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],
});
}

+ 33
- 30
server/sonar-web/src/main/js/app/components/nav/component/__tests__/Menu-test.tsx View File

@@ -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] }
);
}

+ 62
- 58
server/sonar-web/src/main/js/app/components/nav/component/branch-like/BranchLikeNavigation.tsx View File

@@ -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>
</>
);
}


+ 2
- 4
server/sonar-web/src/main/js/app/components/nav/component/branch-like/CurrentBranchLike.tsx View File

@@ -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>
);

+ 0
- 1
server/sonar-web/src/main/js/app/components/nav/component/branch-like/Menu.tsx View File

@@ -176,7 +176,6 @@ export class Menu extends React.PureComponent<Props, State> {
/>
<MenuItemList
branchLikeTree={branchLikesToDisplayTree}
component={component}
hasResults={hasResults}
onSelect={this.handleOnSelect}
selectedBranchLike={selectedBranchLike}

+ 1
- 4
server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItem.tsx View File

@@ -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
/>

+ 1
- 4
server/sonar-web/src/main/js/app/components/nav/component/branch-like/MenuItemList.tsx View File

@@ -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)}

+ 12
- 23
server/sonar-web/src/main/js/app/components/nav/component/branch-like/QualityGateStatus.tsx View File

@@ -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>
);

+ 1
- 3
server/sonar-web/src/main/js/app/components/promotion-notification/PromotionNotification.tsx View File

@@ -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]) {

+ 3
- 2
server/sonar-web/src/main/js/app/components/promotion-notification/__tests__/PromotionNotification-test.tsx View File

@@ -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()}

server/sonar-web/src/main/js/components/common/AnalysisWarningsModal.tsx → server/sonar-web/src/main/js/apps/background-tasks/components/AnalysisWarningsModal.tsx View File

@@ -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
}

+ 1
- 1
server/sonar-web/src/main/js/apps/background-tasks/components/TaskActions.tsx View File

@@ -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';


+ 4
- 21
server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx View File

@@ -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))));

+ 1
- 3
server/sonar-web/src/main/js/apps/code/components/CodeAppRenderer.tsx View File

@@ -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>
)}

+ 2
- 4
server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx View File

@@ -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
/>

+ 7
- 6
server/sonar-web/src/main/js/apps/component-measures/__tests__/ComponentMeasures-it.tsx View File

@@ -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 }
);
}

+ 6
- 27
server/sonar-web/src/main/js/apps/component-measures/components/ComponentMeasuresApp.tsx View File

@@ -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} />;
}

+ 0
- 3
server/sonar-web/src/main/js/apps/component-measures/components/MeasureContent.tsx View File

@@ -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>
) : (

+ 1
- 8
server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverview.tsx View File

@@ -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>
);
}

+ 0
- 3
server/sonar-web/src/main/js/apps/component-measures/components/MeasureOverviewContainer.tsx View File

@@ -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}

+ 6
- 1
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx View File

@@ -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);

+ 4
- 25
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx View File

@@ -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
);


+ 2
- 1
server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx View File

@@ -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(

+ 3
- 3
server/sonar-web/src/main/js/apps/issues/issues-subnavigation/__tests__/SubnavigationIssues-it.tsx View File

@@ -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

+ 1
- 2
server/sonar-web/src/main/js/apps/issues/sidebar/Sidebar.tsx View File

@@ -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" />


+ 7
- 0
server/sonar-web/src/main/js/apps/overview/branches/BranchOverview.tsx View File

@@ -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;
}

+ 6
- 5
server/sonar-web/src/main/js/apps/overview/components/App.tsx View File

@@ -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')} />

+ 13
- 11
server/sonar-web/src/main/js/apps/overview/components/__tests__/App-test.tsx View File

@@ -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'
);
}

+ 119
- 185
server/sonar-web/src/main/js/apps/overview/pullRequests/PullRequestOverview.tsx View File

@@ -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) {

+ 49
- 23
server/sonar-web/src/main/js/apps/overview/pullRequests/__tests__/PullRequestOverview-it.tsx View File

@@ -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' })],

+ 6
- 0
server/sonar-web/src/main/js/apps/projectBaseline/components/BranchList.tsx View File

@@ -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;
}

+ 6
- 3
server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineApp.tsx View File

@@ -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)))
);

+ 5
- 1
server/sonar-web/src/main/js/apps/projectBaseline/components/ProjectBaselineSelector.tsx View File

@@ -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">

+ 29
- 18
server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/ProjectBaselineApp-it.tsx View File

@@ -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' }),

+ 2
- 9
server/sonar-web/src/main/js/apps/projectBranches/ProjectBranchesApp.tsx View File

@@ -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>
);
}

+ 69
- 101
server/sonar-web/src/main/js/apps/projectBranches/__tests__/ProjectBranchesApp-it.tsx View File

@@ -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] }
);
}

+ 2
- 7
server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeRow.tsx View File

@@ -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">

+ 0
- 2
server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTable.tsx View File

@@ -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>

+ 49
- 85
server/sonar-web/src/main/js/apps/projectBranches/components/BranchLikeTabs.tsx View File

@@ -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} />
)}
</>
);
}

+ 24
- 68
server/sonar-web/src/main/js/apps/projectBranches/components/BranchPurgeSetting.tsx View File

@@ -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'
)}
/>
)}
</>
);
}

+ 39
- 72
server/sonar-web/src/main/js/apps/projectBranches/components/DeleteBranchModal.tsx View File

@@ -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>
);
}

+ 48
- 75
server/sonar-web/src/main/js/apps/projectBranches/components/RenameBranchModal.tsx View File

@@ -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>
);
}

+ 2
- 2
server/sonar-web/src/main/js/apps/projects/filters/__tests__/CoverageFilter-test.tsx View File

@@ -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}

+ 2
- 2
server/sonar-web/src/main/js/apps/projects/filters/__tests__/LanguagesFilter-test.tsx View File

@@ -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={{

+ 2
- 2
server/sonar-web/src/main/js/apps/projects/filters/__tests__/QualityGateFilter-test.tsx View File

@@ -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}

+ 4
- 13
server/sonar-web/src/main/js/apps/security-hotspots/SecurityHotspotsApp.tsx View File

@@ -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)))
);

+ 10
- 10
server/sonar-web/src/main/js/apps/security-hotspots/__tests__/SecurityHotspotsApp-it.tsx View File

@@ -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',

+ 8
- 8
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotHeader.tsx View File

@@ -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

+ 0
- 4
server/sonar-web/src/main/js/components/SourceViewer/SourceViewer.tsx View File

@@ -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) => {

+ 0
- 2
server/sonar-web/src/main/js/components/SourceViewer/__tests__/SourceViewer-it.tsx View File

@@ -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()}

+ 4
- 7
server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx View File

@@ -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}>

+ 2
- 2
server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx View File

@@ -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[]>([]);

+ 21
- 17
server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx View File

@@ -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' }
);
}

+ 8
- 9
server/sonar-web/src/main/js/components/common/BranchStatus.tsx View File

@@ -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);

+ 8
- 21
server/sonar-web/src/main/js/components/common/__tests__/AnalysisWarningsModal-test.tsx View File

@@ -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={[

+ 26
- 16
server/sonar-web/src/main/js/components/common/__tests__/BranchStatus-test.tsx View File

@@ -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} />);
}

+ 0
- 15
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BranchStatus-test.tsx.snap View File

@@ -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}
/>
`;

+ 4
- 4
server/sonar-web/src/main/js/components/controls/BoxedTabs.tsx View File

@@ -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}`;
}

+ 83
- 76
server/sonar-web/src/main/js/components/issue/Issue.tsx View File

@@ -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}
/>
);
}

+ 7
- 2
server/sonar-web/src/main/js/components/issue/__tests__/Issue-it.tsx View File

@@ -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 (

+ 11
- 24
server/sonar-web/src/main/js/components/tutorials/TutorialSelection.tsx View File

@@ -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}
/>
);
}
}

+ 12
- 2
server/sonar-web/src/main/js/components/tutorials/TutorialSelectionRenderer.tsx View File

@@ -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" />;
}

+ 15
- 16
server/sonar-web/src/main/js/components/tutorials/__tests__/TutorialSelection-it.tsx View File

@@ -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}

+ 2
- 35
server/sonar-web/src/main/js/components/workspace/WorkspaceComponentViewer.tsx View File

@@ -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);

+ 1
- 23
server/sonar-web/src/main/js/components/workspace/__tests__/WorkspaceComponentViewer-test.tsx View File

@@ -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()}

+ 1
- 1
server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/Workspace-test.tsx.snap View File

@@ -53,7 +53,7 @@ exports[`should render correctly: open component 1`] = `
}
}
/>
<withBranchStatusActions(WorkspaceComponentViewer)
<WorkspaceComponentViewer
component={
{
"branchLike": {

+ 0
- 1
server/sonar-web/src/main/js/components/workspace/__tests__/__snapshots__/WorkspaceComponentViewer-test.tsx.snap View File

@@ -34,7 +34,6 @@ exports[`should render 1`] = `
displayIssueLocationsCount={true}
displayIssueLocationsLink={true}
displayLocationMarkers={true}
onIssueChange={[Function]}
onLoaded={[Function]}
/>
</div>

+ 0
- 11
server/sonar-web/src/main/js/helpers/branch-like.ts View File

@@ -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];
}

+ 15
- 0
server/sonar-web/src/main/js/helpers/mocks/quality-gates.ts View File

@@ -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 {

+ 10
- 10
server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx View File

@@ -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 });
},

+ 29
- 0
server/sonar-web/src/main/js/helpers/testSelector.ts View File

@@ -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));
}

+ 0
- 0
server/sonar-web/src/main/js/helpers/testUtils.ts View File


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save