@gros/sprint-report
Version:
Dynamic sprint report generator in comparison visualization formats.
495 lines (475 loc) • 19.7 kB
JavaScript
/**
* Menu with options to select features to display in the sprint 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 {OrderedSet} from 'immutable';
import {vsprintf} from 'sprintf-js';
import {addDrag, updateOrderTags} from './tabs';
import {getUrl, setToggle} from './url';
import {TOOLTIP_ATTR, LABEL_ATTR} from './attrs';
const PANEL_HEADING = '.panel-heading';
/**
* The feature selection within the configuration panel.
*/
export default class FeatureSelection {
constructor(state, localization, locales) {
this.state = state;
this.localization = localization;
this.locales = locales;
}
/**
* Adjust the item in the feature selection to have a tooltip with a longer
* description or expression of the feature, if available.
*/
setFeatureTooltip(d, link) {
let description = this.locales.retrieve(this.localization.long_descriptions,
d, ""
);
const assignment = this.state.features.format.assignment(d);
if (assignment !== null && !_.includes(assignment, "(")) {
description = description !== "" ?
this.locales.message("features-tooltip-assignment", [
description, assignment
]) : assignment;
}
link.attr(TOOLTIP_ATTR, description)
.classed('tooltip has-tooltip-multiline has-tooltip-center',
description !== ""
);
}
/**
* Determine if a feature should be placed behind a "More" option within
* the category of features, because it is less likely to be useful within
* the current context or confusing to the user.
*/
isHidden(d, formatter) {
if (this.state.features.selected.has(d)) {
return false;
}
if (!formatter.required &&
_.has(this.localization.metadata.preferred, d)
) {
return !this.localization.metadata.preferred[d];
}
return !_.some(formatter.features, group => group.startsWith('!') ?
!this.state.features[group.slice(1)].has(d) :
this.state.features[group].has(d)
);
}
/**
* Create or update categories of features within the selection.
*/
makeFeatureCategories() {
const categories = d3.select('#features').selectAll('.columns')
.data(_.filter(this.localization.categories,
d => !_.isEmpty(d.items)
));
const newCategories = categories.enter()
.append('div').classed('columns', true);
const name = newCategories.append('div')
.classed('column category tooltip', true);
name.attr(TOOLTIP_ATTR,
d => this.locales.message("features-category-hide",
[this.locales.retrieve(d)]
)
);
name.on("click", (d, i, nodes) => {
const tabs = d3.select(nodes[i].parentNode).select('.tabs');
const hidden = tabs.classed("is-hidden");
tabs.classed("is-hidden", false).style("height", null);
const height = tabs.node().clientHeight;
tabs.style("opacity", hidden ? 0 : 1)
.style("height", hidden ? '0px' : `${height}px`)
.transition()
.style("opacity", hidden ? 1 : 0)
.style("height", hidden ? `${height}px` : '0px')
.on("end", () => {
tabs.classed("is-hidden", !hidden);
d3.select(nodes[i]).classed("is-size-7", !hidden)
.attr(TOOLTIP_ATTR, this.locales.message(`features-category-${hidden ? "hide" : "show"}`,
[this.locales.retrieve(d)]
))
.select(".icon").classed("is-small", !hidden)
.select("i").classed("fa-sm", !hidden);
});
});
name.append('span')
.classed('icon', true)
.append('i')
.attr('class', d => d.icon.join(' '));
name.append('span')
.classed('name', true)
.text(d => this.locales.retrieve(d));
newCategories.append('div')
.classed('column features', true)
.append('div')
.classed('tabs is-toggle is-size-7', true)
.append('ul');
const formatter = _.find(this.state.formatter.known,
{name: this.state.formatter.selected}
);
const updateCategories = newCategories.merge(categories);
const tabs = updateCategories.selectAll('.features .tabs ul');
const features = tabs.selectAll('li.item')
.data(d => d.items);
const newFeatures = features.enter()
.append('li')
.classed('item', true);
const label = newFeatures.append('a');
label.each(
(d, i, nodes) => this.setFeatureTooltip(d, d3.select(nodes[i]))
);
label.append('span')
.classed('feature', true)
.text(d => this.locales.retrieve(this.localization.descriptions, d));
label.append('span').classed('tag', true);
label.append('span')
.classed('icon is-small is-hidden', true)
.append('i');
addDrag(this.state, label, {
name: "feature",
items: this.state.features,
key: d => d
});
const mores = tabs.selectAll('li.more')
.data(d => [{
category: d,
message: 'more',
formatter: this.state.formatter.selected
}], d => `${d.formatter}-${d.category}-${d.message}`);
mores.exit().remove();
const updateFeatures = newFeatures.merge(features)
.classed('is-hidden', (d, i, nodes) => {
const more = d3.select(nodes[i].parentNode)
.selectAll('li.more');
return (more.empty() || !more.classed('is-hidden')) &&
this.isHidden(d, formatter);
})
.classed('is-active', d => this.state.features.selected.has(d));
updateFeatures.filter(':not(.is-hidden)')
.classed('is-first', (d, i) => i === 0)
.classed('is-last', (d, i, nodes) => i === nodes.length - 1);
updateFeatures.selectAll('a')
.attr('href', d => getUrl(this.state, {
feature: _.assign({}, this.state.features, {
selected: setToggle(this.state.features.selected, d)
})
}));
updateFeatures.selectAll('.icon i')
.attr('class', (d, i, nodes) => {
const team = this.state.features.team.has(d);
const project = this.state.features.project.has(d);
let classes = null;
if (team && !project) {
classes = 'fas fa-users fa-sm';
}
if (project && !team) {
classes = 'fas fa-sitemap fa-sm';
}
d3.select(nodes[i].parentNode)
.classed('is-hidden', classes === null);
return classes;
});
updateOrderTags(updateFeatures, this.state.features, d => d);
this.updateMore(mores);
}
/**
* Update the "More" options within categories of the feature selection that
* can be clicked to show less prominent features.
*/
updateMore(more) {
const newMore = more.enter()
.append('li')
.classed('more', true);
newMore.append('button')
.classed('button is-text is-size-7 tooltip', true)
.text(d => this.locales.message(`features-${d.message}`))
.attr(TOOLTIP_ATTR,
d => this.locales.message(`features-${d.message}-tooltip`,
[this.locales.retrieve(d.category)]
)
)
.on('click', (d, i, nodes) => {
d3.select(nodes[i].parentNode.parentNode)
.selectAll('li.item')
.classed('is-hidden', false)
.classed('is-first', (d, i) => i === 0)
.classed('is-last', (d, i, nodes) => i === nodes.length - 1);
d3.select(nodes[i].parentNode).classed('is-hidden', true);
});
newMore.merge(more).classed('is-hidden',
(d, i, nodes) => d3.select(nodes[i].parentNode.parentNode)
.select('li.item.is-hidden').empty()
);
}
/**
* Adjust the display of the feature selection to display the categories or
* to hide them such that only the panel of current features is shown.
*/
toggleFeatureSelection(selection, panel, visible=null, duration=500) {
const t = d3.transition("feature-selection").duration(duration);
const hidden = selection.classed('is-hidden');
const features = d3.selectAll('#features .columns');
if (visible !== null && hidden !== visible) {
return;
}
if (hidden) {
features.select('.selection').remove();
selection.classed('is-hidden', false);
}
selection.selectAll('.panel-block, .tabs')
.classed('is-hidden', false)
.style('opacity', hidden ? 0 : 1)
.transition(t)
.style('opacity', hidden ? 1 : 0);
features.selectAll('.column:not(.selection)')
.classed('is-hidden', false)
.style('opacity', hidden ? 0 : 1)
.transition(t)
.style('opacity', hidden ? 1 : 0);
selection.transition(t).on('end', () => {
panel.select(PANEL_HEADING)
.attr('aria-expanded', hidden ? 'true' : 'false')
.attr(LABEL_ATTR, this.locales.message(`features-selection-${hidden ? "collapse" : "expand"}`))
.select('i')
.attr('class', `far fa-${hidden ? "minus" : "plus"}-square`);
selection.selectAll('.panel-block, .tabs')
.classed('is-hidden', !hidden);
features.selectAll('.column:not(.selection)')
.classed('is-hidden', !hidden);
if (!hidden) {
d3.select('#features .columns')
.append('div')
.classed('column selection is-narrow', true)
.append(() => panel.node().cloneNode(true))
.select(PANEL_HEADING)
.on('click', () => this.toggleFeatureSelection(selection, panel));
}
selection.classed('is-hidden', !hidden);
});
}
/**
* Create or update the panel of selected features.
*/
makeSelectedFeatures() {
const selection = d3.select('#feature-selection');
const panel = selection.select('.panel');
panel.select(PANEL_HEADING)
.attr(LABEL_ATTR, this.locales.message('features-selection-collapse'))
.attr('role', 'button')
.on('click', () => this.toggleFeatureSelection(selection, panel));
const selectedFeatures = panel.selectAll('.panel-block')
.data(Array.from(this.state.features.selected));
selectedFeatures.exit().remove();
const newSelection = selectedFeatures.enter()
.append('label')
.classed('panel-block', true);
newSelection.append('span')
.classed('order panel-icon', true);
newSelection.append('span')
.classed('feature', true);
addDrag(this.state, newSelection, {
name: "feature",
items: this.state.features,
key: d => d,
relative: panel.node()
});
this.toggleFeatureSelection(selection, panel, this.state.features.changed, 0);
const updateSelection = newSelection.merge(selectedFeatures).order();
updateSelection.select('.order')
.text((d, i) => `${i + 1}.`);
updateSelection.select('.feature')
.text(d => this.locales.retrieve(this.localization.descriptions, d))
.each(
(d, i, nodes) => this.setFeatureTooltip(d, d3.select(nodes[i]))
);
const icons = updateSelection.selectAll('.icon')
.data((d, i) => [
{
type: 'remove',
name: d,
classes: 'has-tooltip-danger',
icon: 'far fa-times-circle'
},
{
type: 'visibility',
options: ['visible', 'team', 'project', 'hidden'],
option: _.find([
['team', 'project'], ['project', 'team'], ['visible'], ['all']
], option => this.state.features[option[0]].includes(d) &&
(option.length === 1 || !this.state.features[option[1]].includes(d))
)[0],
name: d,
update: {
visible: features => _.assign(features, {
visible: features.visible.add(d)
}),
team: features => _.assign(features, {
project: features.project.remove(d),
team: features.team.add(d)
}),
project: features => _.assign(features, {
team: features.team.remove(d),
project: features.project.add(d)
}),
hidden: features => _.assign(features, {
visible: features.visible.remove(d)
})
},
icons: {
visible: 'fas fa-eye',
team: 'fas fa-users',
project: 'fas fa-sitemap',
hidden: 'fas fa-eye-slash'
}
},
{
type: 'up',
swap: i - 1,
name: d,
icon: 'fas fa-arrow-up'
},
{
type: 'down',
swap: i + 1,
name: d,
icon: 'fas fa-arrow-down'
}
]);
icons.exit().remove();
const newIcon = icons.enter().append('a')
.classed('icon panel-icon tooltip', true);
newIcon.append('i');
this.updateIcons(newIcon.merge(icons));
const selectionButtons = selection.select('.tabs ul')
.selectAll('li')
.data([
{
features: () => this.state.features.default
.subtract(this.state.features.meta),
message: 'reset',
classes: 'is-warning tooltip'
},
{
features: () => OrderedSet(),
message: 'clear',
classes: 'is-danger tooltip'
}
]);
selectionButtons.enter()
.append('li')
.append('a')
.attr('class', d => d.classes)
.attr(TOOLTIP_ATTR, d => this.locales.message(`features-select-${d.message}-tooltip`))
.text(d => this.locales.message(`features-select-${d.message}`))
.merge(selection.selectAll('.tabs ul a'))
.attr('href', d => getUrl(this.state, {
feature: d.features(this.state.features)
}));
}
/**
* Adjust the icons displayed in the rows of the feature selection panel
* to indicate their state.
*/
updateIcons(icons) {
icons.attr('class', d => {
let classes = ['icon', 'panel-icon', 'tooltip'];
if (d.classes) {
classes = _.concat(classes,
d.options ? d.classes[d.option] : d.classes
);
}
return _.join(classes, ' ');
})
.attr('role', 'button')
.attr(TOOLTIP_ATTR,
d => this.locales.attribute('feature-selection-tooltip',
d.options ? `${d.type}-${d.option}` : d.type
)
)
.attr(LABEL_ATTR, d => {
const f = this.locales.retrieve(this.localization.descriptions,
d.name
);
const label = vsprintf(
this.locales.attribute('feature-selection-label',
d.options ? `${d.type}-${d.option}` : d.type
),
[f]
);
if (d.options) {
const next = d.options[(_.findIndex(d.options,
option => option === d.option) + 1
) % d.options.length];
const t = this.locales.attribute('feature-selection-toggle',
`${d.type}-${next}`
);
return `${label} ${t}`;
}
return label;
})
.attr('href', (d, i) => this.getIconUrl(d))
.classed('is-disabled', d => !d.options &&
(d.swap < 0 || d.swap >= this.state.features.selected.size)
)
.on('mousedown touchstart', () => {
d3.event.stopPropagation();
})
.select('i')
.attr('class', d => {
if (d.options) {
return d.icons[d.option];
}
return d.icon;
});
}
/**
* Generate a navigable URL for an icon in the feature selection panel,
* which reflects what changes would happen if the icon is clicked.
*/
getIconUrl(d) {
let features = _.assign({}, this.state.features);
if (d.options) {
const current = d.option;
const next = d.options[(_.findIndex(d.options,
option => option === d.option) + 1
) % d.options.length];
features[current] = features[current].delete(d.name);
features = d.update[next](features);
}
else if (d.type === 'remove') {
features.selected = features.selected.delete(d.name);
}
else {
const selected = Array.from(features.selected);
if (d.swap < 0 || d.swap >= selected.length) {
return null;
}
const other = selected[d.swap];
const swap = _.fromPairs([[d.name, other], [other, d.name]]);
features.selected = OrderedSet(_.map(selected,
name => swap[name] || name
));
}
return getUrl(this.state, {
feature: features
});
}
}