From bd28f67e3f5250a5e4ac5706eeb555f416e15782 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Gr=C3=A9goire=20Aubert?= Date: Wed, 5 Jul 2017 15:55:14 +0200 Subject: [PATCH] SONAR-9415 Click on the graph or on the list to see the matching analysis on project activity page --- .../components/ProjectActivityAnalysesList.js | 69 +++++++-- .../components/ProjectActivityAnalysis.js | 136 ++++++++++-------- .../components/ProjectActivityApp.js | 1 + .../components/ProjectActivityGraphs.js | 5 +- .../components/StaticGraphs.js | 19 +-- .../ProjectActivityApp-test.js.snap | 1 + .../components/projectActivity.css | 8 ++ .../src/main/js/apps/projectActivity/types.js | 3 +- .../src/main/js/apps/projectActivity/utils.js | 25 +++- .../js/components/charts/AdvancedTimeline.js | 54 +++++-- 10 files changed, 212 insertions(+), 109 deletions(-) diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js index a2aa8084c66..9c98102a95e 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.js @@ -25,7 +25,12 @@ import { throttle } from 'lodash'; import ProjectActivityAnalysis from './ProjectActivityAnalysis'; import FormattedDate from '../../../components/ui/FormattedDate'; import { translate } from '../../../helpers/l10n'; -import { activityQueryChanged, getAnalysesByVersionByDay } from '../utils'; +import { + activityQueryChanged, + getAnalysesByVersionByDay, + selectedDateQueryChanged +} from '../utils'; +import type { RawQuery } from '../../../helpers/query'; import type { Analysis, Query } from '../types'; type Props = { @@ -39,13 +44,15 @@ type Props = { deleteAnalysis: (analysis: string) => Promise<*>, deleteEvent: (analysis: string, event: string) => Promise<*>, loading: boolean, - query: Query + query: Query, + updateQuery: RawQuery => void }; export default class ProjectActivityAnalysesList extends React.PureComponent { - scrollContainer: HTMLElement; + analyses: HTMLCollection; badges: HTMLCollection; props: Props; + scrollContainer: HTMLElement; constructor(props: Props) { super(props); @@ -54,22 +61,36 @@ export default class ProjectActivityAnalysesList extends React.PureComponent { componentDidMount() { this.badges = document.getElementsByClassName('project-activity-version-badge'); + this.analyses = document.getElementsByClassName('project-activity-analysis'); } componentDidUpdate(prevProps: Props) { - if (prevProps.analysis !== this.props.analyses && this.scrollContainer) { - if (activityQueryChanged(prevProps.query, this.props.query)) { - this.scrollContainer.scrollTop = 0; - } - for (let i = 1; i < this.badges.length; i++) { - this.badges[i].removeAttribute('originOffsetTop'); - this.badges[i].classList.remove('sticky'); + if (this.scrollContainer) { + const selectedDateChanged = selectedDateQueryChanged(prevProps.query, this.props.query); + if (selectedDateChanged || prevProps.analysis !== this.props.analyses) { + if (selectedDateChanged && this.props.query.selectedDate) { + const selectedDate = this.props.query.selectedDate.valueOf(); + for (let i = 1; i < this.analyses.length; i++) { + if (Number(this.analyses[i].getAttribute('data-date')) === selectedDate) { + const containerHeight = this.scrollContainer.offsetHeight - 100; + const scrollDiff = Math.abs( + this.scrollContainer.scrollTop - this.analyses[i].offsetTop + ); + // Center only the extremities and the ones outside of the container + if (scrollDiff > containerHeight || scrollDiff < 100) { + this.resetScrollTop(this.analyses[i].offsetTop - containerHeight / 2); + } + break; + } + } + } else if (activityQueryChanged(prevProps.query, this.props.query)) { + this.resetScrollTop(0, true); + } } - this.handleScroll(); } } - handleScroll = () => { + updateStickyBadges = (forceBadgeAlignement?: boolean) => { if (this.scrollContainer && this.badges) { const scrollTop = this.scrollContainer.scrollTop; if (scrollTop != null) { @@ -78,11 +99,12 @@ export default class ProjectActivityAnalysesList extends React.PureComponent { const badge = this.badges[i]; let originOffsetTop = badge.getAttribute('originOffsetTop'); if (originOffsetTop == null) { + // Set the originOffsetTop attribute, to avoid using getBoundingClientRect originOffsetTop = badge.offsetTop; badge.setAttribute('originOffsetTop', originOffsetTop.toString()); } if (Number(originOffsetTop) < scrollTop + 18 + i * 2) { - if (!badge.classList.contains('sticky')) { + if (forceBadgeAlignement && !badge.classList.contains('sticky')) { newScrollTop = originOffsetTop; } badge.classList.add('sticky'); @@ -90,12 +112,26 @@ export default class ProjectActivityAnalysesList extends React.PureComponent { badge.classList.remove('sticky'); } } - if (newScrollTop != null) { + if (forceBadgeAlignement && newScrollTop != null) { this.scrollContainer.scrollTop = newScrollTop - 6; } } } }; + handleScroll = () => this.updateStickyBadges(true); + + resetScrollTop = (newScrollTop: number, forceBadgeAlignement?: boolean) => { + this.scrollContainer.scrollTop = newScrollTop; + for (let i = 1; i < this.badges.length; i++) { + this.badges[i].removeAttribute('originOffsetTop'); + this.badges[i].classList.remove('sticky'); + } + this.updateStickyBadges(forceBadgeAlignement); + }; + + updateSelectedDate = (date: Date) => { + this.props.updateQuery({ selectedDate: date }); + }; render() { if (this.props.analyses.length === 0) { @@ -110,6 +146,9 @@ export default class ProjectActivityAnalysesList extends React.PureComponent { const firstAnalysisKey = this.props.analyses[0].key; const byVersionByDay = getAnalysesByVersionByDay(this.props.analyses); + const selectedDate = this.props.query.selectedDate + ? this.props.query.selectedDate.valueOf() + : null; return (
    ))}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js index 1cd17b22bb9..4ccb7cb9718 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.js @@ -19,6 +19,7 @@ */ // @flow import React from 'react'; +import classNames from 'classnames'; import Events from './Events'; import AddEventForm from './forms/AddEventForm'; import RemoveAnalysisForm from './forms/RemoveAnalysisForm'; @@ -34,72 +35,87 @@ type Props = { changeEvent: (event: string, name: string) => Promise<*>, deleteAnalysis: (analysis: string) => Promise<*>, deleteEvent: (analysis: string, event: string) => Promise<*>, - isFirst: boolean + isFirst: boolean, + selected: boolean, + updateSelectedDate: Date => void }; -export default function ProjectActivityAnalysis(props: Props) { - const { date, events } = props.analysis; - const { isFirst, canAdmin } = props; - const analysisTitle = translate('project_activity.analysis'); - const hasVersion = events.find(event => event.category === 'VERSION') != null; - return ( -
  • -
    - -
    -
    +export default class ProjectActivityAnalysis extends React.PureComponent { + props: Props; - {canAdmin && -
    -
    - -
      - {!hasVersion && + handleClick = () => this.props.updateSelectedDate(this.props.analysis.date); + + render() { + const { analysis, isFirst, canAdmin } = this.props; + const { date, events } = analysis; + const analysisTitle = translate('project_activity.analysis'); + const hasVersion = events.find(event => event.category === 'VERSION') != null; + return ( +
    • +
      + +
      +
      + + {canAdmin && +
      +
      + +
        + {!hasVersion && +
      • + +
      • }
      • -
      • } -
      • - -
      • - {!isFirst &&
      • } - {!isFirst && -
      • - -
      • } -
      -
      -
      } +
    • + {!isFirst &&
    • } + {!isFirst && +
    • + +
    • } +
    +
    +
    } - {events.length > 0 && - } + {events.length > 0 && + } -
  • - ); + + ); + } } diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js index dc7d213f30f..46b6dd4fe53 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.js @@ -112,6 +112,7 @@ export default class ProjectActivityApp extends React.PureComponent { deleteEvent={this.props.deleteEvent} loading={this.props.loading} query={this.props.query} + updateQuery={this.props.updateQuery} />
    diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js index 4baa9a82dba..03605c8114c 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js @@ -40,7 +40,6 @@ type Props = { }; type State = { - selectedDate?: ?Date, graphStartDate: ?Date, graphEndDate: ?Date, series: Array @@ -97,7 +96,7 @@ export default class ProjectActivityGraphs extends React.PureComponent { } }; - updateSelectedDate = (selectedDate: ?Date) => this.setState({ selectedDate }); + updateSelectedDate = (selectedDate: ?Date) => this.props.updateQuery({ selectedDate }); updateGraphZoom = (graphStartDate: ?Date, graphEndDate: ?Date) => { if (graphEndDate != null && graphStartDate != null) { @@ -138,7 +137,7 @@ export default class ProjectActivityGraphs extends React.PureComponent { measuresHistory={this.props.measuresHistory} metricsType={metricsType} project={this.props.project} - selectedDate={this.state.selectedDate} + selectedDate={this.props.query.selectedDate} series={series} updateGraphZoom={this.updateGraphZoom} updateSelectedDate={this.updateSelectedDate} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js index fbea7a4d2c8..d07737c8d02 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/StaticGraphs.js @@ -47,6 +47,7 @@ type Props = { }; type State = { + selectedDate?: ?Date, tooltipIdx: ?number, tooltipXPos: ?number }; @@ -86,8 +87,8 @@ export default class StaticGraphs extends React.PureComponent { hasSeriesData = () => some(this.props.series, serie => serie.data && serie.data.length > 2); - updateTooltipPos = (tooltipXPos: ?number, tooltipIdx: ?number) => - this.setState({ tooltipXPos, tooltipIdx }); + updateTooltip = (selectedDate: ?Date, tooltipXPos: ?number, tooltipIdx: ?number) => + this.setState({ selectedDate, tooltipXPos, tooltipIdx }); render() { const { loading } = this.props; @@ -111,8 +112,8 @@ export default class StaticGraphs extends React.PureComponent {
    ); } - - const { graph, selectedDate, series } = this.props; + const { selectedDate, tooltipIdx, tooltipXPos } = this.state; + const { graph, series } = this.props; return (
    @@ -129,16 +130,16 @@ export default class StaticGraphs extends React.PureComponent { formatYTick={this.formatValue} leakPeriodDate={this.props.leakPeriodDate} metricType={this.props.metricsType} - selectedDate={selectedDate} + selectedDate={this.props.selectedDate} series={series} showAreas={['coverage', 'duplications'].includes(graph)} startDate={this.props.graphStartDate} updateSelectedDate={this.props.updateSelectedDate} - updateTooltipPos={this.updateTooltipPos} + updateTooltip={this.updateTooltip} updateZoom={this.props.updateGraphZoom} /> {selectedDate != null && - this.state.tooltipXPos != null && + tooltipXPos != null && }
    )} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityApp-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityApp-test.js.snap index b97c9aa9054..91f1dcdc0e4 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityApp-test.js.snap +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityApp-test.js.snap @@ -117,6 +117,7 @@ exports[`should render correctly 1`] = ` "project": "org.sonarsource.sonarqube:sonarqube", } } + updateQuery={[Function]} />
    - prevQuery.category !== nextQuery.category || - prevQuery.from !== nextQuery.from || - prevQuery.to !== nextQuery.to; + prevQuery.category !== nextQuery.category || datesQueryChanged(prevQuery, nextQuery); -export const datesQueryChanged = (prevQuery: Query, nextQuery: Query): boolean => - prevQuery.from !== nextQuery.from || prevQuery.to !== nextQuery.to; +export const datesQueryChanged = (prevQuery: Query, nextQuery: Query): boolean => { + const nextFrom = nextQuery.from ? nextQuery.from.valueOf() : null; + const previousFrom = prevQuery.from ? prevQuery.from.valueOf() : null; + const nextTo = nextQuery.to ? nextQuery.to.valueOf() : null; + const previousTo = prevQuery.to ? prevQuery.to.valueOf() : null; + return previousFrom !== nextFrom || previousTo !== nextTo; +}; export const historyQueryChanged = (prevQuery: Query, nextQuery: Query): boolean => prevQuery.graph !== nextQuery.graph; +export const selectedDateQueryChanged = (prevQuery: Query, nextQuery: Query): boolean => { + const nextSelectedDate = nextQuery.selectedDate ? nextQuery.selectedDate.valueOf() : null; + const previousSelectedDate = prevQuery.selectedDate ? prevQuery.selectedDate.valueOf() : null; + return nextSelectedDate !== previousSelectedDate; +}; + export const generateCoveredLinesMetric = ( uncoveredLines: MeasureHistory, measuresHistory: Array, @@ -143,7 +152,8 @@ export const parseQuery = (urlQuery: RawQuery): Query => ({ from: parseAsDate(urlQuery['from']), graph: parseGraph(urlQuery['graph']), project: parseAsString(urlQuery['id']), - to: parseAsDate(urlQuery['to']) + to: parseAsDate(urlQuery['to']), + selectedDate: parseAsDate(urlQuery['selected_date']) }); export const serializeQuery = (query: Query): RawQuery => @@ -160,6 +170,7 @@ export const serializeUrlQuery = (query: Query): RawQuery => { from: serializeDate(query.from), graph: serializeGraph(query.graph), id: serializeString(query.project), - to: serializeDate(query.to) + to: serializeDate(query.to), + selected_date: serializeDate(query.selectedDate) }); }; diff --git a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js index fc2123d1fe4..413a53a78d3 100644 --- a/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js +++ b/server/sonar-web/src/main/js/components/charts/AdvancedTimeline.js @@ -50,7 +50,7 @@ type Props = { showEventMarkers?: boolean, startDate: ?Date, updateSelectedDate?: (selectedDate: ?Date) => void, - updateTooltipPos?: (tooltipXPos: ?number, tooltipIdx: ?number) => void, + updateTooltip?: (selectedDate: ?Date, tooltipXPos: ?number, tooltipIdx: ?number) => void, updateZoom?: (start: ?Date, endDate: ?Date) => void, zoomSpeed: number }; @@ -59,6 +59,7 @@ type State = { maxXRange: Array, mouseOver?: boolean, mouseOverlayPos?: { [string]: number }, + selectedDate: ?Date, selectedDateXPos: ?number, selectedDateIdx: ?number, yScale: Scale, @@ -78,8 +79,9 @@ export default class AdvancedTimeline extends React.PureComponent { constructor(props: Props) { super(props); const scales = this.getScales(props); - this.state = { ...scales, ...this.getSelectedDatePos(scales.xScale, props.selectedDate) }; - this.updateSelectedDate = throttle(this.updateSelectedDate, 40); + const selectedDatePos = this.getSelectedDatePos(scales.xScale, props.selectedDate); + this.state = { ...scales, ...selectedDatePos }; + this.updateTooltipPos = throttle(this.updateTooltipPos, 40); } componentWillReceiveProps(nextProps: Props) { @@ -100,8 +102,9 @@ export default class AdvancedTimeline extends React.PureComponent { const xScale = scales ? scales.xScale : this.state.xScale; const selectedDatePos = this.getSelectedDatePos(xScale, nextProps.selectedDate); this.setState({ ...scales, ...selectedDatePos }); - if (nextProps.updateTooltipPos) { - nextProps.updateTooltipPos( + if (nextProps.updateTooltip) { + nextProps.updateTooltip( + selectedDatePos.selectedDate, selectedDatePos.selectedDateXPos, selectedDatePos.selectedDateIdx ); @@ -158,12 +161,13 @@ export default class AdvancedTimeline extends React.PureComponent { this.props.series.some(serie => serie.data[idx].y || serie.data[idx].y === 0) ) { return { + selectedDate, selectedDateXPos: xScale(selectedDate), selectedDateIdx: idx }; } } - return { selectedDateXPos: null, selectedDateIdx: null }; + return { selectedDate: null, selectedDateXPos: null, selectedDateIdx: null }; }; getEventMarker = (size: number) => { @@ -197,31 +201,43 @@ export default class AdvancedTimeline extends React.PureComponent { handleMouseMove = (evt: MouseEvent & { target: HTMLElement }) => { const parentBbox = this.getMouseOverlayPos(evt.target); - this.updateSelectedDate(evt.pageX - parentBbox.left); + this.updateTooltipPos(evt.pageX - parentBbox.left); }; handleMouseEnter = () => this.setState({ mouseOver: true }); handleMouseOut = (evt: Event & { relatedTarget: HTMLElement }) => { - const { updateSelectedDate } = this.props; + const { updateTooltip } = this.props; const targetClass = evt.relatedTarget && typeof evt.relatedTarget.className === 'string' ? evt.relatedTarget.className : ''; if ( - !updateSelectedDate || + !updateTooltip || targetClass.includes('bubble-popup') || targetClass.includes('graph-tooltip') ) { return; } - this.setState({ mouseOver: false }); - updateSelectedDate(null); + this.setState({ + mouseOver: false, + selectedDate: null, + selectedDateXPos: null, + selectedDateIdx: null + }); + updateTooltip(null, null, null); }; - updateSelectedDate = (xPos: number) => { + handleClick = () => { const { updateSelectedDate } = this.props; + if (updateSelectedDate) { + updateSelectedDate(this.state.selectedDate); + } + }; + + updateTooltipPos = (xPos: number) => { const firstSerie = this.props.series[0]; - if (this.state.mouseOver && firstSerie && updateSelectedDate) { + if (this.state.mouseOver && firstSerie) { + const { updateTooltip } = this.props; const date = this.state.xScale.invert(xPos); const bisectX = bisector(d => d.x).right; let idx = bisectX(firstSerie.data, date); @@ -231,7 +247,12 @@ export default class AdvancedTimeline extends React.PureComponent { if (!nextPoint || (previousPoint && date - previousPoint.x <= nextPoint.x - date)) { idx--; } - updateSelectedDate(firstSerie.data[idx].x); + const selectedDate = firstSerie.data[idx].x; + const xPos = this.state.xScale(selectedDate); + this.setState({ selectedDate, selectedDateXPos: xPos, selectedDateIdx: idx }); + if (updateTooltip) { + updateTooltip(selectedDate, xPos, idx); + } } } }; @@ -428,11 +449,14 @@ export default class AdvancedTimeline extends React.PureComponent { if (zoomEnabled) { mouseEvents.onWheel = this.handleWheel; } - if (this.props.updateSelectedDate) { + if (this.props.updateTooltip) { mouseEvents.onMouseEnter = this.handleMouseEnter; mouseEvents.onMouseMove = this.handleMouseMove; mouseEvents.onMouseOut = this.handleMouseOut; } + if (this.props.updateSelectedDate) { + mouseEvents.onClick = this.handleClick; + } return (