From b6982a7a1a7f67d24a0c4bef4e5937d0a5abe821 Mon Sep 17 00:00:00 2001 From: Wouter Admiraal Date: Thu, 29 Dec 2022 08:05:41 +0100 Subject: [PATCH] SONAR-10740 Display each project's last analysis date in Portfolio breakdown --- .../main/js/apps/code/__tests__/Code-it.ts | 14 +++- .../sonar-web/src/main/js/apps/code/code.css | 54 +++++++++---- .../main/js/apps/code/components/CodeApp.tsx | 5 +- .../js/apps/code/components/Component.tsx | 53 ++++++------- .../js/apps/code/components/Components.tsx | 65 ++++++++-------- .../apps/code/components/ComponentsEmpty.tsx | 3 +- .../apps/code/components/ComponentsHeader.tsx | 38 ++++------ .../code/components/SourceViewerWrapper.tsx | 2 +- .../__tests__/SourceViewerWrapper-test.tsx | 76 +++++++++++++++++++ .../sonar-web/src/main/js/apps/code/utils.ts | 42 ++++++---- .../components/intl/__mocks__/DateFromNow.tsx | 2 +- server/sonar-web/src/main/js/types/types.ts | 1 + .../resources/org/sonar/l10n/core.properties | 3 + 13 files changed, 237 insertions(+), 121 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/code/components/__tests__/SourceViewerWrapper-test.tsx diff --git a/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts b/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts index f495b4b5da9..88011dfa728 100644 --- a/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts +++ b/server/sonar-web/src/main/js/apps/code/__tests__/Code-it.ts @@ -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; } diff --git a/server/sonar-web/src/main/js/apps/code/code.css b/server/sonar-web/src/main/js/apps/code/code.css index 285d20c9ab9..4a74cd5c19b 100644 --- a/server/sonar-web/src/main/js/apps/code/code.css +++ b/server/sonar-web/src/main/js/apps/code/code.css @@ -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; diff --git a/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx b/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx index 1bb29530c1c..53bc79a18e1 100644 --- a/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/CodeApp.tsx @@ -292,10 +292,12 @@ export class CodeApp extends React.Component { ? translate('projects.page') : translate('code.page'); + const isPortfolio = isPortfolioLike(qualifier); + return (
- {!canBrowseAllChildProjects && isPortfolioLike(qualifier) && ( + {!canBrowseAllChildProjects && isPortfolio && ( {translate('code_viewer.not_all_measures_are_shown')} @@ -358,6 +360,7 @@ export class CodeApp extends React.Component { rootComponent={component} selected={highlighted} newCodeSelected={newCodeSelected} + showAnalysisDate={isPortfolio} />
diff --git a/server/sonar-web/src/main/js/apps/code/components/Component.tsx b/server/sonar-web/src/main/js/apps/code/components/Component.tsx index 245e9188b88..7393e3f7556 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Component.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/Component.tsx @@ -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 { @@ -57,6 +58,7 @@ export class Component extends React.PureComponent { rootComponent, selected = false, newCodeSelected, + showAnalysisDate, } = this.props; const isFile = @@ -64,22 +66,19 @@ export class Component extends React.PureComponent { component.qualifier === ComponentQualifier.TestFile; return ( - - + {canBePinned && ( {isFile && ( - - - {({ openComponent }) => ( - - )} - - + + {({ openComponent }) => ( + + )} + )} )} @@ -99,24 +98,18 @@ export class Component extends React.PureComponent { {metrics.map((metric) => ( - -
- -
+ + ))} - + + {showAnalysisDate && isBaseComponent && } + + {showAnalysisDate && !isBaseComponent && ( + + {component.analysisDate ? : '—'} + + )} ); } diff --git a/server/sonar-web/src/main/js/apps/code/components/Components.tsx b/server/sonar-web/src/main/js/apps/code/components/Components.tsx index 84fb036497c..3e0c96d8a0e 100644 --- a/server/sonar-web/src/main/js/apps/code/components/Components.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/Components.tsx @@ -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 { - 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 ( - + return ( +
+
{baseComponent && ( metric.key)} rootComponent={rootComponent} + showAnalysisDate={showAnalysisDate} /> )} @@ -77,14 +84,12 @@ export class Components extends React.PureComponent { metrics={metrics} rootComponent={rootComponent} newCodeSelected={newCodeSelected} + showAnalysisDate={showAnalysisDate} /> - - + )} @@ -103,10 +108,11 @@ export class Components extends React.PureComponent { 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 { ) : ( )} - - -
-
-
-
-
- -
- ); - } + + ); } export default withKeyboardNavigation(Components); diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.tsx b/server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.tsx index 2c3e345f5d8..c6214240a13 100644 --- a/server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentsEmpty.tsx @@ -28,10 +28,9 @@ export default function ComponentsEmpty({ canBePinned = true }: Props) { return ( {canBePinned && } - + {translate('no_results')} - ); } diff --git a/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx b/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx index f5149241d1a..6041ebe807f 100644 --- a/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/ComponentsHeader.tsx @@ -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 ( - - + {canBePinned && } + {baseComponent && - columns.map((column, index) => ( - 0, - nowrap: !isPortfolio, - 'text-center': isPortfolio && index < columns.length - 1, - 'text-right': !isPortfolio || index === columns.length - 1, - })} - key={column} - > + columns.map((column) => ( + {column} ))} - ); diff --git a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx index 2c96a3d4fab..634327f86e3 100644 --- a/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx +++ b/server/sonar-web/src/main/js/apps/code/components/SourceViewerWrapper.tsx @@ -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; diff --git a/server/sonar-web/src/main/js/apps/code/components/__tests__/SourceViewerWrapper-test.tsx b/server/sonar-web/src/main/js/apps/code/components/__tests__/SourceViewerWrapper-test.tsx new file mode 100644 index 00000000000..c7ad0fe7058 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/code/components/__tests__/SourceViewerWrapper-test.tsx @@ -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 = {}) { + return renderComponent( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/code/utils.ts b/server/sonar-web/src/main/js/apps/code/utils.ts index 437b2c3d7a1..c1363ecf84e 100644 --- a/server/sonar-web/src/main/js/apps/code/utils.ts +++ b/server/sonar-web/src/main/js/apps/code/utils.ts @@ -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( diff --git a/server/sonar-web/src/main/js/components/intl/__mocks__/DateFromNow.tsx b/server/sonar-web/src/main/js/components/intl/__mocks__/DateFromNow.tsx index 3c912090934..8304c482b39 100644 --- a/server/sonar-web/src/main/js/components/intl/__mocks__/DateFromNow.tsx +++ b/server/sonar-web/src/main/js/components/intl/__mocks__/DateFromNow.tsx @@ -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(); } diff --git a/server/sonar-web/src/main/js/types/types.ts b/server/sonar-web/src/main/js/types/types.ts index a449b2a5935..07e7367f97e 100644 --- a/server/sonar-web/src/main/js/types/types.ts +++ b/server/sonar-web/src/main/js/types/types.ts @@ -113,6 +113,7 @@ export interface ComponentQualityProfile { } export interface ComponentMeasureIntern { + analysisDate?: string; branch?: string; description?: string; isFavorite?: boolean; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 257dd0798c0..f42e670123d 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -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 #------------------------------------------------------------------------------ -- 2.39.5