diff options
Diffstat (limited to 'server')
9 files changed, 374 insertions, 26 deletions
diff --git a/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx b/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx index b737b1bbd07..c894d53d819 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx @@ -98,6 +98,7 @@ export function ActivityPanel(props: ActivityPanelProps) { 'overview.activity.graph_shows_data_for_x', displayedMetrics.map(metricKey => localizeMetric(metricKey)).join(', ') )} + canShowDataAsTable={false} graph={graph} graphs={graphs} leakPeriodDate={shownLeakPeriodDate} diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap index ca28c6eba5e..1a33e48545e 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap @@ -39,6 +39,7 @@ exports[`should render correctly 1`] = ` <GraphsHistory analyses={Array []} ariaLabel="overview.activity.graph_shows_data_for_x.metric.bugs.name, metric.code_smells.name, metric.vulnerabilities.name" + canShowDataAsTable={false} graph="issues" graphs={ Array [ @@ -208,6 +209,7 @@ exports[`should render correctly 2`] = ` <GraphsHistory analyses={Array []} ariaLabel="overview.activity.graph_shows_data_for_x.metric.bugs.name, metric.code_smells.name, metric.vulnerabilities.name" + canShowDataAsTable={false} graph="issues" graphs={ Array [ diff --git a/server/sonar-web/src/main/js/components/activity-graph/DataTableModal.tsx b/server/sonar-web/src/main/js/components/activity-graph/DataTableModal.tsx new file mode 100644 index 00000000000..8cfea76f95c --- /dev/null +++ b/server/sonar-web/src/main/js/components/activity-graph/DataTableModal.tsx @@ -0,0 +1,188 @@ +/* + * 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 { filter, slice, sortBy } from 'lodash'; +import * as React from 'react'; +import { FormattedMessage } from 'react-intl'; +import EventInner from '../../apps/projectActivity/components/EventInner'; +import { translate, translateWithParameters } from '../../helpers/l10n'; +import { formatMeasure } from '../../helpers/measures'; +import { ParsedAnalysis, Serie } from '../../types/project-activity'; +import { Button } from '../controls/buttons'; +import Modal from '../controls/Modal'; +import DateFormatter from '../intl/DateFormatter'; +import TimeFormatter from '../intl/TimeFormatter'; +import { Alert } from '../ui/Alert'; +import { getAnalysisEventsForDate } from './utils'; + +export interface DataTableModalProps { + analyses: ParsedAnalysis[]; + graphEndDate?: Date; + graphStartDate?: Date; + series: Serie[]; + onClose: () => void; +} + +type DataTableEntry = { date: Date } & { [x: string]: string | undefined }; + +const MAX_DATA_TABLE_ROWS = 100; + +export default function DataTableModal(props: DataTableModalProps) { + const { analyses, series, graphEndDate, graphStartDate } = props; + + if (series.length === 0) { + return renderModal( + props, + <Alert variant="info"> + {translate('project_activity.graphs.data_table.no_data_warning')} + </Alert> + ); + } + + const tableData = series.reduce((acc, serie) => { + const data = filter( + serie.data, + // Make sure we respect the date filtering. On the graph, this is done by dynamically + // "zooming" on the series. Here, we actually have to "cut off" part of the serie's + // data points. + ({ x }) => { + if (graphEndDate && x > graphEndDate) { + return false; + } + if (graphStartDate && x < graphStartDate) { + return false; + } + return true; + } + ); + + data.forEach(({ x, y }) => { + const key = x.getTime(); + if (acc[key] === undefined) { + acc[key] = { date: x } as DataTableEntry; + } + + if (y !== undefined && !(typeof y === 'number' && isNaN(y))) { + acc[key][serie.name] = formatMeasure(y, serie.type); + } + }); + + return acc; + }, {} as { [x: number]: DataTableEntry }); + + const metrics = series.map(({ name }) => name); + const rows = slice( + sortBy(Object.values(tableData), ({ date }) => -date), + 0, + MAX_DATA_TABLE_ROWS + ).map(({ date, ...values }) => ( + <tr key={date.getTime()}> + <td className="nowrap"> + <DateFormatter long={true} date={date} /> + <div className="small note"> + <TimeFormatter date={date} /> + </div> + </td> + {metrics.map(metric => ( + <td key={metric} className="thin nowrap"> + {values[metric] || '-'} + </td> + ))} + <td> + <ul> + {getAnalysisEventsForDate(analyses, date).map(event => ( + <li className="little-spacer-bottom" key={event.key}> + <EventInner event={event} readonly={true} /> + </li> + ))} + </ul> + </td> + </tr> + )); + + const rowCount = rows.length; + + if (rowCount === 0) { + const start = graphStartDate && <DateFormatter long={true} date={graphStartDate} />; + const end = graphEndDate && <DateFormatter long={true} date={graphEndDate} />; + let suffix = ''; + if (start && end) { + suffix = '_x_y'; + } else if (start) { + suffix = '_x'; + } else if (end) { + suffix = '_y'; + } + return renderModal( + props, + <Alert variant="info"> + <FormattedMessage + defaultMessage={translate( + `project_activity.graphs.data_table.no_data_warning_check_dates${suffix}` + )} + id={`project_activity.graphs.data_table.no_data_warning_check_dates${suffix}`} + values={{ start, end }} + /> + </Alert> + ); + } + + return renderModal( + props, + <> + {rowCount === MAX_DATA_TABLE_ROWS && ( + <Alert variant="info"> + {translateWithParameters( + 'project_activity.graphs.data_table.max_lines_warning', + MAX_DATA_TABLE_ROWS + )} + </Alert> + )} + <table className="spacer-top data zebra"> + <thead> + <tr> + <th>{translate('date')}</th> + {series.map(serie => ( + <th key={serie.name} className="thin nowrap"> + {serie.translatedName} + </th> + ))} + <th>{translate('events')}</th> + </tr> + </thead> + <tbody>{rows}</tbody> + </table> + </> + ); +} + +function renderModal(props: DataTableModalProps, children: React.ReactNode) { + const heading = translate('project_activity.graphs.data_table.title'); + return ( + <Modal onRequestClose={props.onClose} contentLabel={heading} size="medium"> + <div className="modal-head"> + <h2>{heading}</h2> + </div> + <div className="modal-body modal-container">{children}</div> + <div className="modal-foot"> + <Button onClick={props.onClose}>{translate('close')}</Button> + </div> + </Modal> + ); +} diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx index 33117b15773..f19056711d3 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphHistory.tsx @@ -20,15 +20,21 @@ import * as React from 'react'; import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'; import AdvancedTimeline from '../../components/charts/AdvancedTimeline'; +import { translate } from '../../helpers/l10n'; import { formatMeasure, getShortType } from '../../helpers/measures'; import { MeasureHistory, Serie } from '../../types/project-activity'; -import { AnalysisEvent } from '../../types/types'; +import { ParsedAnalysis } from '../../types/types'; +import { Button } from '../controls/buttons'; +import ModalButton from '../controls/ModalButton'; +import DataTableModal from './DataTableModal'; import GraphsLegendCustom from './GraphsLegendCustom'; import GraphsLegendStatic from './GraphsLegendStatic'; import GraphsTooltips from './GraphsTooltips'; +import { getAnalysisEventsForDate } from './utils'; interface Props { - events: AnalysisEvent[]; + analyses: ParsedAnalysis[]; + canShowDataAsTable?: boolean; graph: string; graphEndDate?: Date; graphStartDate?: Date; @@ -69,7 +75,8 @@ export default class GraphHistory extends React.PureComponent<Props, State> { render() { const { - events, + analyses, + canShowDataAsTable = true, graph, graphEndDate, graphStartDate, @@ -83,6 +90,7 @@ export default class GraphHistory extends React.PureComponent<Props, State> { graphDescription } = this.props; const { tooltipIdx, tooltipXPos } = this.state; + const events = getAnalysisEventsForDate(analyses, selectedDate); return ( <div className="activity-graph-container flex-grow display-flex-column display-flex-stretch display-flex-justify-center"> @@ -132,6 +140,24 @@ export default class GraphHistory extends React.PureComponent<Props, State> { )} </AutoSizer> </div> + {canShowDataAsTable && ( + <ModalButton + modal={({ onClose }) => ( + <DataTableModal + analyses={analyses} + graphEndDate={graphEndDate} + graphStartDate={graphStartDate} + series={series} + onClose={onClose} + /> + )}> + {({ onClick }) => ( + <Button className="a11y-hidden" onClick={onClick}> + {translate('project_activity.graphs.open_in_table')} + </Button> + )} + </ModalButton> + )} </div> ); } diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsHistory.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsHistory.tsx index f0d4a97f820..288bd4773b3 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsHistory.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsHistory.tsx @@ -31,6 +31,7 @@ import { getSeriesMetricType, hasHistoryData, isCustomGraph } from './utils'; interface Props { analyses: ParsedAnalysis[]; ariaLabel?: string; + canShowDataAsTable?: boolean; graph: GraphType; graphs: Serie[][]; graphEndDate?: Date; @@ -63,31 +64,19 @@ export default class GraphsHistory extends React.PureComponent<Props, State> { } } - getSelectedDateEvents = () => { - const { selectedDate } = this.state; - const { analyses } = this.props; - if (analyses && selectedDate) { - const analysis = analyses.find(a => a.date.valueOf() === selectedDate.valueOf()); - if (analysis) { - return analysis.events; - } - } - return []; - }; - updateTooltip = (selectedDate?: Date) => { this.setState({ selectedDate }); }; render() { - const { graph, loading, series, ariaLabel } = this.props; + const { analyses, graph, loading, series, ariaLabel, canShowDataAsTable } = this.props; const isCustom = isCustomGraph(graph); if (loading) { return ( <div className="activity-graph-container flex-grow display-flex-column display-flex-stretch display-flex-justify-center"> <div className="text-center"> - <DeferredSpinner loading={loading} /> + <DeferredSpinner ariaLabel={translate('loading')} loading={loading} /> </div> </div> ); @@ -114,14 +103,14 @@ export default class GraphsHistory extends React.PureComponent<Props, State> { </div> ); } - const events = this.getSelectedDateEvents(); const showAreas = [GraphType.coverage, GraphType.duplications].includes(graph); return ( <div className="display-flex-justify-center display-flex-column display-flex-stretch flex-grow"> {this.props.graphs.map((graphSeries, idx) => { return ( <GraphHistory - events={events} + analyses={analyses} + canShowDataAsTable={canShowDataAsTable} graph={graph} graphEndDate={this.props.graphEndDate} graphStartDate={this.props.graphStartDate} diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx index 921fd843e6a..4c5b61eb82a 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx @@ -23,7 +23,7 @@ import ZoomTimeLine from '../../components/charts/ZoomTimeLine'; import { Serie } from '../../types/project-activity'; import { hasHistoryData } from './utils'; -interface Props { +interface GraphsZoomProps { graphEndDate?: Date; graphStartDate?: Date; leakPeriodDate?: Date; @@ -34,13 +34,14 @@ interface Props { updateGraphZoom: (from?: Date, to?: Date) => void; } -export default function GraphsZoom(props: Props) { +export default function GraphsZoom(props: GraphsZoomProps) { if (props.loading || !hasHistoryData(props.series)) { return null; } return ( - <div className="activity-graph-zoom"> + // We hide this for screen readers; they should use date inputs instead. + <div className="activity-graph-zoom" aria-hidden={true}> <AutoSizer disableHeight={true}> {({ width }) => ( <ZoomTimeLine diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphHistory-test.tsx.snap b/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphHistory-test.tsx.snap index bcab20a45b0..5c0af0d1425 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphHistory-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphHistory-test.tsx.snap @@ -36,6 +36,19 @@ exports[`should correctly render a graph 1`] = ` <Component /> </AutoSizer> </div> + <div + className="spacer-top big-spacer-bottom small" + > + <div + className="display-flex-justify-center" + > + <ModalButton + modal={[Function]} + > + <Component /> + </ModalButton> + </div> + </div> </div> `; @@ -76,5 +89,18 @@ exports[`should correctly render a graph: custom 1`] = ` <Component /> </AutoSizer> </div> + <div + className="spacer-top big-spacer-bottom small" + > + <div + className="display-flex-justify-center" + > + <ModalButton + modal={[Function]} + > + <Component /> + </ModalButton> + </div> + </div> </div> `; diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphsHistory-test.tsx.snap b/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphsHistory-test.tsx.snap index b7d7f3fadc7..1bb06610746 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphsHistory-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/GraphsHistory-test.tsx.snap @@ -5,8 +5,43 @@ exports[`should correctly render a graph 1`] = ` className="display-flex-justify-center display-flex-column display-flex-stretch flex-grow" > <GraphHistory + analyses={ + Array [ + Object { + "date": 2016-10-27T14:33:50.000Z, + "events": Array [ + Object { + "category": "VERSION", + "key": "E1", + "name": "6.5-SNAPSHOT", + }, + ], + "key": "A1", + }, + Object { + "date": 2016-10-27T10:21:15.000Z, + "events": Array [], + "key": "A2", + }, + Object { + "date": 2016-10-26T10:17:29.000Z, + "events": Array [ + Object { + "category": "OTHER", + "key": "E2", + "name": "foo", + }, + Object { + "category": "VERSION", + "key": "E3", + "name": "6.4", + }, + ], + "key": "A3", + }, + ] + } ariaLabel="project_activity.graphs.explanation_x.metric.bugs.name" - events={Array []} graph="issues" isCustom={false} key="0" @@ -50,8 +85,43 @@ exports[`should correctly render multiple graphs 1`] = ` className="display-flex-justify-center display-flex-column display-flex-stretch flex-grow" > <GraphHistory + analyses={ + Array [ + Object { + "date": 2016-10-27T14:33:50.000Z, + "events": Array [ + Object { + "category": "VERSION", + "key": "E1", + "name": "6.5-SNAPSHOT", + }, + ], + "key": "A1", + }, + Object { + "date": 2016-10-27T10:21:15.000Z, + "events": Array [], + "key": "A2", + }, + Object { + "date": 2016-10-26T10:17:29.000Z, + "events": Array [ + Object { + "category": "OTHER", + "key": "E2", + "name": "foo", + }, + Object { + "category": "VERSION", + "key": "E3", + "name": "6.4", + }, + ], + "key": "A3", + }, + ] + } ariaLabel="project_activity.graphs.explanation_x.metric.bugs.name" - events={Array []} graph="issues" isCustom={false} key="0" @@ -88,8 +158,43 @@ exports[`should correctly render multiple graphs 1`] = ` updateTooltip={[Function]} /> <GraphHistory + analyses={ + Array [ + Object { + "date": 2016-10-27T14:33:50.000Z, + "events": Array [ + Object { + "category": "VERSION", + "key": "E1", + "name": "6.5-SNAPSHOT", + }, + ], + "key": "A1", + }, + Object { + "date": 2016-10-27T10:21:15.000Z, + "events": Array [], + "key": "A2", + }, + Object { + "date": 2016-10-26T10:17:29.000Z, + "events": Array [ + Object { + "category": "OTHER", + "key": "E2", + "name": "foo", + }, + Object { + "category": "VERSION", + "key": "E3", + "name": "6.4", + }, + ], + "key": "A3", + }, + ] + } ariaLabel="project_activity.graphs.explanation_x.metric.bugs.name" - events={Array []} graph="issues" isCustom={false} key="1" diff --git a/server/sonar-web/src/main/js/components/activity-graph/utils.ts b/server/sonar-web/src/main/js/components/activity-graph/utils.ts index 141c3d5c479..46e3714d696 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/utils.ts +++ b/server/sonar-web/src/main/js/components/activity-graph/utils.ts @@ -23,7 +23,7 @@ import { localizeMetric } from '../../helpers/measures'; import { get, save } from '../../helpers/storage'; import { MetricKey } from '../../types/metrics'; import { GraphType, MeasureHistory, Serie } from '../../types/project-activity'; -import { Dict, Metric } from '../../types/types'; +import { Dict, Metric, ParsedAnalysis } from '../../types/types'; export const DEFAULT_GRAPH = GraphType.issues; @@ -159,6 +159,16 @@ export function getActivityGraph( }; } +export function getAnalysisEventsForDate(analyses: ParsedAnalysis[], date?: Date) { + if (date) { + const analysis = analyses.find(a => a.date.valueOf() === date.valueOf()); + if (analysis) { + return analysis.events; + } + } + return []; +} + function findMetric(key: string, metrics: Metric[] | Dict<Metric>) { if (Array.isArray(metrics)) { return metrics.find(metric => metric.key === key); |