Browse Source

SONAR-10740 Display each project's last analysis date in Portfolio breakdown

tags/9.9.0.65466
Wouter Admiraal 1 year ago
parent
commit
b6982a7a1a

+ 10
- 4
server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts View File

@@ -41,6 +41,8 @@ jest.mock('../../../api/issues');
jest.mock('../../../api/rules');
jest.mock('../../../api/users');

jest.mock('../../../components/intl/DateFromNow');

jest.mock('../../../components/SourceViewer/helpers/lines', () => {
const lines = jest.requireActual('../../../components/SourceViewer/helpers/lines');
return {
@@ -246,7 +248,7 @@ it('should correctly show measures for a project', async () => {
['coverage', '2.0%'],
['duplicated_lines_density', '2.0%'],
].forEach(([domain, value]) => {
expect(ui.measureValueCell(folderRow, domain, value, 1)).toBeInTheDocument();
expect(ui.measureValueCell(folderRow, domain, value)).toBeInTheDocument();
});

// index.tsx
@@ -260,7 +262,7 @@ it('should correctly show measures for a project', async () => {
['coverage', '—'],
['duplicated_lines_density', '—'],
].forEach(([domain, value]) => {
expect(ui.measureValueCell(fileRow, domain, value, 1)).toBeInTheDocument();
expect(ui.measureValueCell(fileRow, domain, value)).toBeInTheDocument();
});
});

@@ -278,6 +280,7 @@ it('should correctly show new VS overall measures for Portfolios', async () => {
children: [
{
component: mockComponent({
analysisDate: '2022-02-01',
key: 'child1',
name: 'Child 1',
}),
@@ -314,6 +317,7 @@ it('should correctly show new VS overall measures for Portfolios', async () => {
['security_hotspots', 'C'],
['Maintainability', 'C'],
['ncloc', '3'],
['last_analysis_date', '2022-02-01'],
].forEach(([domain, value]) => {
expect(ui.measureValueCell(child1Row, domain, value)).toBeInTheDocument();
});
@@ -327,6 +331,7 @@ it('should correctly show new VS overall measures for Portfolios', async () => {
['security_hotspots', '—'],
['Maintainability', '—'],
['ncloc', '—'],
['last_analysis_date', '—'],
].forEach(([domain, value]) => {
expect(ui.measureValueCell(child2Row, domain, value)).toBeInTheDocument();
});
@@ -375,7 +380,7 @@ function getPageObject(user: UserEvent) {
newCodeBtn: byRole('button', { name: 'projects.view.new_code' }),
overallCodeBtn: byRole('button', { name: 'projects.view.overall_code' }),
measureRow: (name: string | RegExp) => byRole('row', { name, exact: false }),
measureValueCell: (row: HTMLElement, name: string, value: string, offset = 0) => {
measureValueCell: (row: HTMLElement, name: string, value: string) => {
const i = Array.from(screen.getAllByRole('columnheader')).findIndex((c) =>
c.textContent?.includes(name)
);
@@ -386,7 +391,8 @@ function getPageObject(user: UserEvent) {
}

const { getAllByRole } = within(row);
const cell = getAllByRole('cell').at(i + offset);
const cell = getAllByRole('cell').at(i);

if (cell?.textContent === value) {
return cell;
}

+ 41
- 13
server/sonar-web/src/main/js/apps/code/code.css View File

@@ -29,6 +29,47 @@
margin-top: -50px;
}

.code-components .table-wrapper {
margin: 0 20px;
}

.code-components table.data {
table-layout: fixed;
}

.code-components table.data td {
padding: 8px 6px;
vertical-align: middle;
}

.code-components table.data th {
padding-top: 24px;
}

.code-components table.data th,
.code-components table.data td:not(.thin) {
width: 84px;
}

.code-components table.data td.code-name-cell,
.code-components table.data th.code-name-cell {
width: auto;
}

.code-components table.data th.thin,
.code-components table.data td.thin {
width: 10px !important;
}

.code-components table.data tr.current-folder {
border-bottom: 1px solid var(--barBorderColor);
}

.code-components table.data tr.current-folder td {
padding-bottom: 16px !important;
padding-top: 10px !important;
}

.code-breadcrumbs {
display: flex;
flex-wrap: wrap;
@@ -56,19 +97,6 @@
display: none;
}

.code-components-cell {
padding-left: calc(2 * var(--gridSize)) !important;
box-sizing: border-box;
}

.code-components-rating-cell {
width: 110px;
}

.code-name-cell {
max-width: 0;
}

@media (max-width: 1200px) {
.code-name-cell .badge {
display: none;

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

@@ -292,10 +292,12 @@ export class CodeApp extends React.Component<Props, State> {
? translate('projects.page')
: translate('code.page');

const isPortfolio = isPortfolioLike(qualifier);

return (
<div className="page page-limited">
<A11ySkipTarget anchor="code_main" />
{!canBrowseAllChildProjects && isPortfolioLike(qualifier) && (
{!canBrowseAllChildProjects && isPortfolio && (
<StyledAlert variant="warning" className="it__portfolio_warning">
<AlertContent>
{translate('code_viewer.not_all_measures_are_shown')}
@@ -358,6 +360,7 @@ export class CodeApp extends React.Component<Props, State> {
rootComponent={component}
selected={highlighted}
newCodeSelected={newCodeSelected}
showAnalysisDate={isPortfolio}
/>
</div>
<ListFooter count={components.length} loadMore={this.handleLoadMore} total={total} />

+ 23
- 30
server/sonar-web/src/main/js/apps/code/components/Component.tsx View File

@@ -20,10 +20,10 @@
import classNames from 'classnames';
import * as React from 'react';
import { withScrollTo } from '../../../components/hoc/withScrollTo';
import DateFromNow from '../../../components/intl/DateFromNow';
import { WorkspaceContext } from '../../../components/workspace/context';
import { BranchLike } from '../../../types/branch-like';
import { ComponentQualifier } from '../../../types/component';
import { MetricType } from '../../../types/metrics';
import { ComponentMeasure as TypeComponentMeasure, Metric } from '../../../types/types';
import ComponentMeasure from './ComponentMeasure';
import ComponentName from './ComponentName';
@@ -41,6 +41,7 @@ interface Props {
rootComponent: TypeComponentMeasure;
selected?: boolean;
newCodeSelected?: boolean;
showAnalysisDate?: boolean;
}

export class Component extends React.PureComponent<Props> {
@@ -57,6 +58,7 @@ export class Component extends React.PureComponent<Props> {
rootComponent,
selected = false,
newCodeSelected,
showAnalysisDate,
} = this.props;

const isFile =
@@ -64,22 +66,19 @@ export class Component extends React.PureComponent<Props> {
component.qualifier === ComponentQualifier.TestFile;

return (
<tr className={classNames({ selected })}>
<td className="blank" />
<tr className={classNames({ selected, 'current-folder': isBaseComponent })}>
{canBePinned && (
<td className="thin nowrap">
{isFile && (
<span className="spacer-right">
<WorkspaceContext.Consumer>
{({ openComponent }) => (
<ComponentPin
branchLike={branchLike}
component={component}
openComponent={openComponent}
/>
)}
</WorkspaceContext.Consumer>
</span>
<WorkspaceContext.Consumer>
{({ openComponent }) => (
<ComponentPin
branchLike={branchLike}
component={component}
openComponent={openComponent}
/>
)}
</WorkspaceContext.Consumer>
)}
</td>
)}
@@ -99,24 +98,18 @@ export class Component extends React.PureComponent<Props> {
</td>

{metrics.map((metric) => (
<td
className={classNames('thin', {
'text-center': metric.type === MetricType.Rating,
'nowrap text-right': metric.type !== MetricType.Rating,
})}
key={metric.key}
>
<div
className={classNames({
'code-components-rating-cell': metric.type === MetricType.Rating,
'code-components-cell': metric.type !== MetricType.Rating,
})}
>
<ComponentMeasure component={component} metric={metric} />
</div>
<td className="text-center" key={metric.key}>
<ComponentMeasure component={component} metric={metric} />
</td>
))}
<td className="blank" />

{showAnalysisDate && isBaseComponent && <td />}

{showAnalysisDate && !isBaseComponent && (
<td className="text-center">
{component.analysisDate ? <DateFromNow date={component.analysisDate} /> : '—'}
</td>
)}
</tr>
);
}

+ 33
- 32
server/sonar-web/src/main/js/apps/code/components/Components.tsx View File

@@ -22,12 +22,13 @@ import * as React from 'react';
import withKeyboardNavigation from '../../../components/hoc/withKeyboardNavigation';
import { getComponentMeasureUniqueKey } from '../../../helpers/component';
import { BranchLike } from '../../../types/branch-like';
import { ComponentQualifier } from '../../../types/component';
import { ComponentMeasure, Metric } from '../../../types/types';
import Component from './Component';
import ComponentsEmpty from './ComponentsEmpty';
import ComponentsHeader from './ComponentsHeader';

interface Props {
interface ComponentsProps {
baseComponent?: ComponentMeasure;
branchLike?: BranchLike;
components: ComponentMeasure[];
@@ -35,33 +36,39 @@ interface Props {
rootComponent: ComponentMeasure;
selected?: ComponentMeasure;
newCodeSelected?: boolean;
showAnalysisDate?: boolean;
}

const BASE_COLUMN_COUNT = 4;
export function Components(props: ComponentsProps) {
const {
baseComponent,
branchLike,
components,
rootComponent,
selected,
metrics,
newCodeSelected,
showAnalysisDate,
} = props;

export class Components extends React.PureComponent<Props> {
render() {
const {
baseComponent,
branchLike,
components,
rootComponent,
selected,
metrics,
newCodeSelected,
} = this.props;
const canBePinned =
baseComponent &&
![
ComponentQualifier.Application,
ComponentQualifier.Portfolio,
ComponentQualifier.SubPortfolio,
].includes(baseComponent.qualifier as ComponentQualifier);

const colSpan = metrics.length + BASE_COLUMN_COUNT;
const canBePinned = baseComponent && !['APP', 'VW', 'SVW'].includes(baseComponent.qualifier);

return (
<table className="data boxed-padding zebra">
return (
<div className="big-spacer-bottom table-wrapper">
<table className="data zebra">
{baseComponent && (
<ComponentsHeader
baseComponent={baseComponent}
canBePinned={canBePinned}
metrics={metrics.map((metric) => metric.key)}
rootComponent={rootComponent}
showAnalysisDate={showAnalysisDate}
/>
)}
<tbody>
@@ -77,14 +84,12 @@ export class Components extends React.PureComponent<Props> {
metrics={metrics}
rootComponent={rootComponent}
newCodeSelected={newCodeSelected}
showAnalysisDate={showAnalysisDate}
/>
<tr className="blank">
<td colSpan={3}>
<hr className="null-spacer-top" />
</td>
<td colSpan={colSpan}>
<hr className="null-spacer-top" />
</td>
<td
colSpan={metrics.length + 1 + (canBePinned ? 1 : 0) + (showAnalysisDate ? 1 : 0)}
/>
</tr>
</>
)}
@@ -103,10 +108,11 @@ export class Components extends React.PureComponent<Props> {
component={component}
hasBaseComponent={baseComponent !== undefined}
key={getComponentMeasureUniqueKey(component)}
metrics={this.props.metrics}
metrics={metrics}
previous={index > 0 ? list[index - 1] : undefined}
rootComponent={rootComponent}
newCodeSelected={newCodeSelected}
showAnalysisDate={showAnalysisDate}
selected={
selected &&
getComponentMeasureUniqueKey(component) === getComponentMeasureUniqueKey(selected)
@@ -116,15 +122,10 @@ export class Components extends React.PureComponent<Props> {
) : (
<ComponentsEmpty canBePinned={canBePinned} />
)}

<tr className="blank">
<td colSpan={3} />
<td colSpan={colSpan} />
</tr>
</tbody>
</table>
);
}
</div>
);
}

export default withKeyboardNavigation(Components);

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

@@ -28,10 +28,9 @@ export default function ComponentsEmpty({ canBePinned = true }: Props) {
return (
<tr>
{canBePinned && <td />}
<td className="note" colSpan={2}>
<td className="note" colSpan={10}>
{translate('no_results')}
</td>
<td colSpan={10} />
</tr>
);
}

+ 15
- 23
server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx View File

@@ -17,16 +17,17 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import * as React from 'react';
import { translate } from '../../../helpers/l10n';
import { isPortfolioLike } from '../../../types/component';
import { ComponentMeasure } from '../../../types/types';

interface Props {
interface ComponentsHeaderProps {
baseComponent?: ComponentMeasure;
canBePinned?: boolean;
metrics: string[];
rootComponent: ComponentMeasure;
showAnalysisDate?: boolean;
}

const SHORT_NAME_METRICS = [
@@ -36,13 +37,9 @@ const SHORT_NAME_METRICS = [
'new_duplicated_lines_density',
];

export default function ComponentsHeader({
baseComponent,
canBePinned = true,
metrics,
rootComponent,
}: Props) {
const isPortfolio = ['VW', 'SVW'].includes(rootComponent.qualifier);
export default function ComponentsHeader(props: ComponentsHeaderProps) {
const { baseComponent, canBePinned = true, metrics, rootComponent, showAnalysisDate } = props;
const isPortfolio = isPortfolioLike(rootComponent.qualifier);
let columns: string[] = [];
if (isPortfolio) {
columns = [
@@ -51,8 +48,12 @@ export default function ComponentsHeader({
translate('portfolio.metric_domain.vulnerabilities'),
translate('portfolio.metric_domain.security_hotspots'),
translate('metric_domain.Maintainability'),
translate('metric', 'ncloc', 'name'),
translate('metric.ncloc.name'),
];

if (showAnalysisDate) {
columns.push(translate('code.last_analysis_date'));
}
} else {
columns = metrics.map((metric) =>
translate('metric', metric, SHORT_NAME_METRICS.includes(metric) ? 'short_name' : 'name')
@@ -62,23 +63,14 @@ export default function ComponentsHeader({
return (
<thead>
<tr className="code-components-header">
<th className="thin nowrap" colSpan={canBePinned ? 2 : 1} />
<th />
{canBePinned && <th className="thin" aria-label={translate('code.pin')} />}
<th className="code-name-cell" aria-label={translate('code.name')} />
{baseComponent &&
columns.map((column, index) => (
<th
className={classNames('thin', {
'code-components-cell': !isPortfolio && index > 0,
nowrap: !isPortfolio,
'text-center': isPortfolio && index < columns.length - 1,
'text-right': !isPortfolio || index === columns.length - 1,
})}
key={column}
>
columns.map((column) => (
<th className="text-center" key={column}>
{column}
</th>
))}
<th />
</tr>
</thead>
);

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

@@ -24,7 +24,7 @@ import SourceViewer from '../../../components/SourceViewer/SourceViewer';
import { BranchLike } from '../../../types/branch-like';
import { Issue, Measure } from '../../../types/types';

interface SourceViewerWrapperProps {
export interface SourceViewerWrapperProps {
branchLike?: BranchLike;
component: string;
componentMeasures: Measure[] | undefined;

+ 76
- 0
server/sonar-web/src/main/js/apps/code/components/__tests__/SourceViewerWrapper-test.tsx View File

@@ -0,0 +1,76 @@
/*
* SonarQube
* Copyright (C) 2009-2022 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 { screen } from '@testing-library/react';
import * as React from 'react';
import ComponentsServiceMock from '../../../../api/mocks/ComponentsServiceMock';
import IssuesServiceMock from '../../../../api/mocks/IssuesServiceMock';
import { mockLocation } from '../../../../helpers/testMocks';
import { renderComponent } from '../../../../helpers/testReactTestingUtils';
import SourceViewerWrapper, { SourceViewerWrapperProps } from '../SourceViewerWrapper';

jest.mock('../../../../api/components');
jest.mock('../../../../api/issues');
// The following 2 mocks are needed, because IssuesServiceMock mocks more than it should.
// This should be removed once IssuesServiceMock is cleaned up.
jest.mock('../../../../api/rules');
jest.mock('../../../../api/users');

const issuesHandler = new IssuesServiceMock();
const componentsHandler = new ComponentsServiceMock();
// eslint-disable-next-line testing-library/no-node-access
const originalQuerySelector = document.querySelector;
const scrollIntoView = jest.fn();

beforeAll(() => {
Object.defineProperty(document, 'querySelector', {
writable: true,
value: () => ({ scrollIntoView }),
});
});

afterAll(() => {
Object.defineProperty(document, 'querySelector', {
writable: true,
value: originalQuerySelector,
});
});

beforeEach(() => {
issuesHandler.reset();
componentsHandler.reset();
});

it('should scroll to a line directly', async () => {
renderSourceViewerWrapper();
await screen.findAllByText('function Test() {}');
expect(scrollIntoView).toHaveBeenCalled();
});

function renderSourceViewerWrapper(props: Partial<SourceViewerWrapperProps> = {}) {
return renderComponent(
<SourceViewerWrapper
component="foo:index.tsx"
componentMeasures={[]}
location={mockLocation({ query: { line: '2' } })}
{...props}
/>
);
}

+ 28
- 14
server/sonar-web/src/main/js/apps/code/utils.ts View File

@@ -17,10 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { getBreadcrumbs, getChildren, getComponent } from '../../api/components';
import { getBreadcrumbs, getChildren, getComponent, getComponentData } from '../../api/components';
import { getBranchLikeQuery, isPullRequest } from '../../helpers/branch-like';
import { BranchLike } from '../../types/branch-like';
import { isPortfolioLike } from '../../types/component';
import { ComponentQualifier, isPortfolioLike } from '../../types/component';
import { MetricKey } from '../../types/metrics';
import { Breadcrumb, ComponentMeasure } from '../../types/types';
import {
@@ -128,7 +128,7 @@ export function getCodeMetrics(
}
return options.includeQGStatus ? metrics.concat(MetricKey.alert_status) : metrics;
}
if (qualifier === 'APP') {
if (qualifier === ComponentQualifier.Application) {
return [...APPLICATION_METRICS];
}
if (showLeakMeasure(branchLike)) {
@@ -162,7 +162,7 @@ function retrieveComponentBase(
});
}

export function retrieveComponentChildren(
export async function retrieveComponentChildren(
componentKey: string,
qualifier: string,
instance: { mounted: boolean },
@@ -181,20 +181,34 @@ export function retrieveComponentChildren(
includeQGStatus: true,
});

return getChildren(componentKey, metrics, {
const result = await getChildren(componentKey, metrics, {
ps: PAGE_SIZE,
s: 'qualifier,name',
...getBranchLikeQuery(branchLike),
})
.then(prepareChildren)
.then((r) => {
if (instance.mounted) {
addComponentChildren(componentKey, r.components, r.total, r.page);
storeChildrenBase(r.components);
storeChildrenBreadcrumbs(componentKey, r.components);
}).then(prepareChildren);

if (instance.mounted && isPortfolioLike(qualifier)) {
await Promise.all(
result.components.map((c) => getComponentData({ component: c.refKey || c.key }))
).then(
(data) => {
data.forEach(({ component: { analysisDate } }, i) => {
result.components[i].analysisDate = analysisDate;
});
},
() => {
// noop
}
return r;
});
);
}

if (instance.mounted) {
addComponentChildren(componentKey, result.components, result.total, result.page);
storeChildrenBase(result.components);
storeChildrenBreadcrumbs(componentKey, result.components);
}

return result;
}

function retrieveComponentBreadcrumbs(

+ 1
- 1
server/sonar-web/src/main/js/components/intl/__mocks__/DateFromNow.tsx View File

@@ -26,5 +26,5 @@ interface Props {
}

export default function DateFromNow({ children, date }: Props) {
return children && children(date.toString());
return children ? children(date.toString()) : date.toString();
}

+ 1
- 0
server/sonar-web/src/main/js/types/types.ts View File

@@ -113,6 +113,7 @@ export interface ComponentQualityProfile {
}

export interface ComponentMeasureIntern {
analysisDate?: string;
branch?: string;
description?: string;
isFavorite?: boolean;

+ 3
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -3385,6 +3385,9 @@ code.open_component_page=Open Component's Page
code.search_placeholder=Search for files...
code.search_placeholder.portfolio=Search for projects and sub-portfolios...
code.parent_folder=Parent folder
code.last_analysis_date=Last analysis
code.name=Name
code.pin=Pin file


#------------------------------------------------------------------------------

Loading…
Cancel
Save