UNPKG

@gros/sprint-report

Version:

Dynamic sprint report generator in comparison visualization formats.

414 lines (387 loc) 15.2 kB
/** * Menu with selection options for number of sprints to display in the report. * * Copyright 2017-2020 ICTU * Copyright 2017-2022 Leiden University * Copyright 2017-2023 Leon Helwerda * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import _ from 'lodash'; import * as d3 from 'd3'; import {addDrag, updateOrderTags} from './tabs'; import {getUrl, setToggle} from './url'; import {TOOLTIP_ATTR, LABEL_ATTR} from './attrs'; /** * The sprint selection within the configuration panel. */ export default class SprintSelection { constructor(state, localization, locales) { this.state = state; this.localization = localization; this.locales = locales; d3.select(window) .on("resize.sprint", () => requestAnimationFrame(() => { this.makeSprintBrush(); })); } /** * Determine whether the future sprints should be displayed in the sprint * selection based on the support of the formatter, features, and sprints. */ hasFuture() { return this.state.formatter.current !== null && this.state.sprints.future > 0 && this.state.formatter.current.requestConfig().future && !this.state.features.selected .intersect(this.state.features.future).isEmpty(); } /** * Determine a list of tick labels to display for the sprint selection, such * that they remain enough distance while being representative enough for * the range of sprints. */ makeSprintTicks(x, numSprints, futureSprints, transform) { let ticks = x.ticks(Math.min(numSprints + futureSprints, 10)); const tickDistance = Math.abs(x.invert(90) - x.invert(0)); const futureDistance = Math.abs(x.invert(110) - x.invert(0)); const ends = transform([ ticks => [0, 1], ticks => [ticks.length - 1, -1] ]); const [future, futureDir] = ends[0](ticks); let futureCount = futureDir; while (ticks[future + futureCount] < 0 && Math.abs(ticks[future + futureCount] + futureSprints) < futureDistance ) { futureCount += futureDir; } ticks.splice(Math.min(future, future + futureCount - futureDir), Math.abs(futureCount), -futureSprints ); const [past, pastDir] = ends[1](ticks); let pastCount = pastDir; while (Math.abs(numSprints - ticks[past + pastCount]) < tickDistance) { pastCount += pastDir; } ticks.splice(Math.min(past, past + pastCount - pastDir), Math.abs(pastCount), numSprints ); return ticks; } /** * Determine a more descriptive message for a certain tick, such as those * at the current sprint, the earliest sprint or the last future sprint. */ formatSprintTick(d, low, high) { if (d === 0) { return this.locales.message("sprints-select-current"); } else if (d === low) { return this.locales.message("sprints-select-future", [Math.abs(d)]); } else if (d === high) { return this.locales.message("sprints-select-past", [d]); } return Math.abs(d); } /** * Determine the highest value among all projects for a numeric field. * * Valid names of the field are 'num_sprints' and 'future_sprints'. If there * are no selected projects with these values, then the `defaultValue` is * returned instead. */ max(name, defaultValue) { const meta = _.maxBy(_.filter(this.state.projects.meta, p => this.state.projects.visible.includes(p.name) ), p => p[name]); if (!meta) { return defaultValue; } return meta[name]; } /** * Create a tooltip above the sprint selection brush area, indicating what * the current range means for the selected sprints in a human-readable * message. */ makeBrushTooltip(count, num) { let tooltip; const [first, current, last] = num; if (first < 0) { tooltip = `sprints-count-tooltip-future${current === first || current === 0 ? "-recent" : ""}`; } else { tooltip = `sprints-count-tooltip${first === 0 ? "-recent" : ""}`; } return count.attr(TOOLTIP_ATTR, this.locales.message(tooltip, [ last - current, last, -first ])); } /** * Create or update the sprint selection brush area. */ makeSprintBrush() { const numSprints = this.max('num_sprints', this.state.sprints.limit); const futureSprints = this.hasFuture() ? this.max('future_sprints', 0) : 0; const last = Math.max(0, Math.min(this.state.sprints.last, numSprints) ); const first = Math.max(-futureSprints, Math.min(this.state.sprints.first, last) ); const current = this.state.sprints.current === 0 ? first : this.state.sprints.current; const count = d3.select('#sprints-count') .call((element) => this.makeBrushTooltip(element, [first, current, last] )) .select('svg'); const width = Math.min(500, count.node().parentNode.parentNode.clientWidth / 2 ); count.attr("width", width); const height = count.attr("height") - 16; const transform = this.state.formatter.selected === 'table' ? _.identity : _.reverse; const domain = transform([-futureSprints, numSprints]); const x = d3.scaleLinear() .domain(domain) .rangeRound([0, width]); const axis = d3.axisBottom(x) .tickValues( this.makeSprintTicks(x, numSprints, futureSprints, transform) ) .tickFormat( d => this.formatSprintTick(d, -futureSprints, numSprints) ); const svg = count.select('g'); svg.select('g.axis') .attr("transform", `translate(0, ${height})`) .call(axis); const brush = this.makeBrushTarget(width, height, { x, transform, filter: () => !d3.event.button, // Disallow selecting subset of future sprints that does not // start at the end time url: pos => pos[0] > 0 ? [Math.max(first, pos[0]), pos[0], Math.max(0, pos[1])] : [pos[0], 0, Math.max(0, pos[1])] }); const disjointBrush = this.makeBrushTarget(width, height, { x, transform, filter: () => !d3.event.button && !d3.select(d3.event.target).classed('selection'), url: pos => this.clampFutureBrush(pos[0], pos[1], current, last), }); svg.select('g.brush') .call(brush) .call(brush.move, transform([x(current), x(last)])); // Display another brush for disjoint future sprints const disjoint = svg.select('g.brush.disjoint') .classed('is-hidden', first === current) .call(disjointBrush) .call(disjointBrush.move, transform([x(first), x(0)])); disjoint.select('.overlay') .attr('pointer-events', 'none'); disjoint.select('.selection') .attr('cursor', 'not-allowed'); const future = transform([x(-futureSprints), x(0)]); svg.select('rect.future') .attr('x', future[0]) .attr('width', future[1] - future[0]); } /** * Create a subarea within the sprint selection brush area that can be * selected as a contiguous range. */ makeBrushTarget(width, height, callbacks) { let current = [0, 0]; const count = d3.select('#sprints-count'); return d3.brushX() .extent([[0, 0], [width, height + 16]]) .filter(() => callbacks.filter()) .on("start", () => { count.classed('has-tooltip-active', true); }) .on("brush", () => { if (!d3.event.sourceEvent || !d3.event.selection) { return; } const pos = callbacks.transform( d3.event.selection.map(callbacks.x.invert).map(Math.round) ); if (!_.isEqual(pos, current)) { current = pos; this.makeBrushTooltip(count, callbacks.url(current)); } }) .on("end", () => { count.classed('has-tooltip-active', false); if (!d3.event.sourceEvent || !d3.event.selection) { return; } const pos = callbacks.transform( d3.event.selection.map(callbacks.x.invert).map(Math.round) ); window.location = getUrl(this.state, { count: callbacks.url(pos) }); }); } /** * Make an adjusted sprint selection based on the disjoint future brush. */ clampFutureBrush(start, end, current, last) { if (end < 0) { return [start - end, current, last]; } if (start >= 0) { return [start, start, Math.max(end, last)]; } return [start, end > current ? 0 : current, last]; } /** * Create or update the buttons in the sprint selection that allow the * selection to be adjusted by steps or as a whole. */ makeSprintSelect() { const buttons = [ { id: 'sprints-reset', icon: 'fas fa-history', active: state => true, url: state => getUrl(state, { count: [0, 0, state.sprints.limit] }) }, { id: 'sprints-minus-one', icon: 'fas fa-minus', active: state => state.sprints.last > 0, url: state => getUrl(state, { count: [ state.sprints.first, state.sprints.current, state.sprints.last - 1 ] }) }, { id: 'sprints-plus-one', icon: 'fas fa-plus', active: state => state.sprints.last < this.max('num_sprints', state.sprints.limit), url: state => getUrl(state, { count: [ state.sprints.first, state.sprints.current, state.sprints.last + 1 ] }) }, { id: 'sprints-all', icon: 'fas fa-arrows-alt-h', active: state => state.sprints.last < this.max('num_sprints', state.sprints.limit), url: state => getUrl(state, { count: [ Math.min(0, state.sprints.first), 0, this.max('num_sprints', state.sprints.limit) ] }) }, { id: 'sprints-future', icon: 'fas fa-ellipsis-h', active: state => this.hasFuture(), url: state => getUrl(state, { count: [ this.hasFuture() ? -this.max('future_sprints', 0) : 0, state.sprints.current, state.sprints.last ] }) } ]; const button = d3.select('#sprints-select') .selectAll('a') .data(buttons); const newButtons = button.enter() .append('a') .classed('button is-small is-outlined is-light has-text-grey-darker tooltip', true) .attr('id', d => d.id) .attr('role', 'button') .attr(TOOLTIP_ATTR, d => this.locales.message(d.id)) .attr(LABEL_ATTR, d => this.locales.message(d.id)); newButtons.append('span') .classed('icon', true) .append('i') .attr('class', d => d.icon); button.merge(newButtons) .attr('disabled', d => d.active(this.state) ? null : true) .attr('href', d => d.active(this.state) ? d.url(this.state) : null); } /** * Create or update the filter checkbox that determines if open sprints are * included in the report. */ makeSprintFilter() { const onlyClosed = this.state.sprints.closed ? true : null; const closed = d3.select('#sprints-closed') .attr('disabled', onlyClosed) .select('input') .attr('disabled', onlyClosed) .attr('checked', onlyClosed || this.state.sprints.closedOnly ? true : null ) .on('change.close', () => { window.location = getUrl(this.state, { closed: [closed.property('checked') ? '1' : '0'] }); }); } /** * Create or update the sprint metadata field selection. */ makeSprintMeta() { const meta = d3.select('#sprints-meta ul').selectAll('li').data( _.concat(['sprint-meta-header'], this.state.sprint_meta.known), function(d) { return d || this.id; } ); const newMeta = meta.enter().append('li'); const label = newMeta.append('a') .classed('tooltip has-tooltip-multiline has-tooltip-center', true); label.append('span') .classed('meta', true) .text(d => this.locales.attribute("sprint_meta", d)); label.append('span').classed('tag', true); addDrag(this.state, label, { name: "meta", items: this.state.sprint_meta, key: d => d }); const updateMeta = newMeta.merge(meta) .classed('is-active', d => this.state.sprint_meta.selected.has(d)); updateMeta.selectAll('a') .attr(TOOLTIP_ATTR, d => this.locales.message(`sprint-meta-${this.state.sprint_meta.selected.has(d) ? "deselect" : "select"}`, [this.locales.attribute("sprint-meta-tooltip", d)] )) .attr('href', d => getUrl(this.state, { meta: setToggle(this.state.sprint_meta.selected, d) })); updateMeta.selectAll('input') .property('checked', d => this.state.sprint_meta.selected.has(d)); updateOrderTags(updateMeta, this.state.sprint_meta, d => d); } }