@gros/sprint-report
Version:
Dynamic sprint report generator in comparison visualization formats.
998 lines (936 loc) • 37.4 kB
JavaScript
/**
* Sprint report builder.
*
* 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 {Navigation} from '@gros/visualization-ui';
import config from 'config.json';
import {TOOLTIP_ATTR, LABEL_ATTR} from './attrs';
import format from './format';
import exports from './export';
import {getUrl, setToggle} from './url';
import {addDrag, updateOrderTags} from './tabs';
import FeatureSelection from './FeatureSelection';
import FormatSelection from './FormatSelection';
import SprintSelection from './SprintSelection';
import SourceAge from './SourceAge';
/**
* Builder that controls the sprint report page.
*/
class Builder {
constructor(projects, features, locales, localization, sprints) {
// Project/team/component details:
// - All projects (known)
// - The ones that are teams (and their associated project IDs)
// - Whether they should be shown when selected by the user (some teams
// may be set as invisible to avoid displaying an aggregate of the
// projects when the team does not need that)
// - The selection of the user
// - The projects that will be visible in the report
this.projects = {
known: _.map(projects, "name"),
teams: OrderedSet(_.map(_.filter(projects,
d => _.isArray(d.project_names) || _.isArray(d.project_ids)),
"name"
)),
invisible: OrderedSet(_.map(_.filter(projects,
d => d.team === -1), "name"
)),
selected: OrderedSet(),
visible: OrderedSet()
};
// Collect the other details of the projects as metadata. This includes
// recent, core, team, component, num_sprints, future_sprints fields,
// as well as project_ids/project_names (the former only in anonymized
// reports), fixversions, and accessible.
const meta = _.zipObject(this.projects.known, _.map(projects,
project => {
if (_.isArray(project.project_ids)) {
project.project_names = _.map(project.project_ids,
id => `Proj${id}`
);
}
return _.assign({}, project, {accessible: true});
}
));
this.projects.meta = projects[0].fixversions ? _.mapValues(meta,
project => _.assign({}, project, {
fixversions: _.merge(..._.map(_.isArray(project.project_names) ?
project.project_names : [project.project_names],
key => _.get(meta[key], 'fixversions', {})
))
})
) : meta;
// Use the best display name of the project, or null if this would be
// superfluous compared to the normal name (for example in table format)
this.projects.display_names = _.zipObject(this.projects.known,
_.map(projects, (meta) =>
meta.name !== meta.quality_display_name ?
meta.quality_display_name : null
)
);
// Feature details:
// - The initial selection (default)
// - Known features and sprint metadata fields (all)
// - Features with predictions (future)
// - Features with structured source traces (details)
// - Features collected from a quality dashboard (metrics)
// - Sprint metadata fields (meta)
// Also includes features with expressions, and the following:
// - All features excluding sprint metadata fields (known)
// - The selection of the user, also split out to be shown only for
// team or project
// - The names of features based on an expression (compound)
// - The names of features used within an expression (attributes)
this.features = _.mapValues(features, group => _.isArray(group) ?
OrderedSet(group) : group
);
this.features.known = Array.from(
this.features.all.subtract(this.features.meta)
);
this.features.selected = this.features.default
.subtract(this.features.meta);
this.features.team = this.features.selected;
this.features.project = this.features.selected;
this.features.visible = this.features.selected;
this.features.compound = OrderedSet(_.keys(this.features.expressions));
this.features.attributes = OrderedSet(
_.flatten(_.map(this.features.expressions,
expression => expression.attributes
))
);
this.features.format = {
assignment: (feature, locale, values) => this.getAssignment(feature,
locale, values
)
};
// Track whether the user made a different selection than the default,
// including whether to show a feature only for team or project
this.features.changed = false;
this.locales = locales;
this.localization = localization;
this.localization.sources.feature = _.mapValues(
this.localization.sources.feature, features => new Set(features)
);
// Output format types and state (selected format, old output, data used
// and all known formats)
this.formatter = {
selected: format.known[0].name,
current: null,
data: null,
known: format.known
};
this.formatter.classes = _.fromPairs(_.map(this.formatter.known,
(formatter) => [formatter.name, format[formatter.class]]
));
// Export types
this.exporter = {
known: _.filter(exports.known,
exporter => !exporter.config || config[exporter.config] !== ""
)
};
this.exporter.classes = _.fromPairs(_.map(this.exporter.known,
(exporter) => [exporter.name, exports[exporter.class]]
));
// Sprint selection state:
// - Default number of sprints to show (limit)
// - Whether the data contains only sprints that have ended (closed)
// - Whether the data contains sprints earlier than the limit (old)
// - The number of sprints that can be shown for predictions (future)
// As well as:
// - Number of sprints to go back from most recent sprint, or number
// of future sprints to show if negative (first)
// - Number of sprints to go back to start showing (current)
// - Number of sprints to go back to stop showing at (last)
// - Whether to filter out sprints that are still open (closedOnly)
this.sprints = _.assign({}, sprints, {
first: 0,
current: 0,
last: sprints.limit,
closedOnly: false
});
// Sprint metadata fields:
// - All the meta (known)
// - The fields to shown in the report (selected)
// - Whether the field contains only numbers (numeric)
// - If the user made a different selection than the default (changed)
// - Formatters for longer displays of metadata values
this.sprint_meta = {
known: _.intersection(
['sprint_name', 'sprint_num', 'start_date', 'close_date'],
Array.from(this.features.meta)
),
selected: OrderedSet(['sprint_name', 'sprint_num', 'close_date'])
.intersect(this.features.meta),
numeric: OrderedSet(['sprint_num']),
changed: false,
format: {
sprint_name: (d, node) => this.formatSprintName(d, node),
sprint_num: (d) => this.locales.message('sprint-number', [d]),
start_date: (d, node) => this.formatDate(d, node, "start_date"),
close_date: (d, node) => this.formatDate(d, node, "close_date")
}
};
// Actions to take when the user navigates to or clicks on a link with
// an anchor that changes which items are shown
this.navigationHooks = {
feature: (keys) => this.updateFeatures(keys),
format: (formatter) => this.updateFormat(formatter[0]),
count: (num) => this.updateCount(num),
closed: (closed) => {
this.sprints.closedOnly = closed[0] === '1';
},
meta: (meta) => this.updateMeta(meta),
config: (visible) => this.updateConfig(visible)
};
// Toggles that expand or collapse parts of the configuration panel
this.configToggles = {
config: (toggle, d, show) => this.toggleConfig(toggle, d, show),
sources: (toggle, d, show) => this.toggleSources(show)
};
}
/**
* Formatter for a sprint name metadata field.
*
* See also Format.formatSprint
*/
formatSprintName(data, node, key) {
if (_.isArray(data)) {
if (node.classed('meta')) {
node.selectAll('tspan')
.data(data)
.enter()
.append('tspan')
.classed('has-source', true)
.text(d => d)
.filter(':not(:last-child)').each(function() {
this.insertAdjacentText('afterend', ', ');
});
return null;
}
else {
return _.join(data, ', ');
}
}
else if (data === null || typeof data === "undefined") {
return this.locales.message("ellipsis");
}
return data;
}
/**
* Formatter for a sprint start or end date metadata field.
*
* See also `Format.formatSprint`.
*/
formatDate(data, node, key) {
const date = this.localization.moment(data,
["YYYY-MM-DD HH:mm:ss", "YYYY-MM-DD"], true
);
const description = this.locales.attribute("sprint_meta", key);
const title = this.locales.message("date-title",
[description, date.format()]
);
if (node.classed('meta')) {
node.append('title').text(title);
}
else if (!node.classed('title')) {
node.attr('title', title);
}
return date.format('ll');
}
/**
* Generate a URL that considers the current state of the report plus new
* selections of which items to show.
*/
getUrl(selections) {
return getUrl(this.getState(), selections);
}
/**
* Select the features for inclusion in the report. The keys are an array
* of feature keys (possibly prefixed with `team~` or `project~`).
*/
updateFeatures(keys) {
const features = _.intersection(_.map(keys, feature =>
feature.includes("~") ? feature.split("~", 2)[1] : feature
), this.features.known);
if (this.features.visible.equals(this.features.selected)) {
this.features.visible = OrderedSet(features);
}
else {
const added = _.difference(features,
Array.from(this.features.selected)
);
const removed = this.features.selected.subtract(features);
this.features.visible = this.features.visible
.withMutations((visible) => {
visible.union(added).subtract(removed);
});
}
this.features.selected = OrderedSet(features);
this.features.team = OrderedSet(_.intersection(_.map(keys,
feature => feature.startsWith("team~") ?
feature.split("~", 2)[1] : feature
), this.features.known));
this.features.project = OrderedSet(_.intersection(_.map(keys,
feature => feature.startsWith("project~") ?
feature.split("~", 2)[1] : feature
), this.features.known));
const normal = this.features.default.subtract(this.features.meta);
if (!this.features.team.equals(normal) ||
!this.features.project.equals(normal)
) {
this.features.changed = true;
}
}
/**
* Select another format type to use for the report.
*/
updateFormat(formatter) {
if ((this.formatter.current === null ||
formatter !== this.formatter.selected) &&
_.some(this.formatter.known, format => format.name === formatter)) {
this.formatter.selected = formatter;
this.formatter.current = new this.formatter.classes[formatter](this.locales, this.localization);
}
}
/**
* Select the range of sprints to display. This function is provided with
* an array of at least one, but most preferably three, numbers, which are
* interpreted as the `last` sprint (in case of 1), the `first` and `last`
* sprint (in case of 2 items), or the `first`, `current` and `last` sprints
* for 3 items (any more are ignored).
*/
updateCount(num) {
if (!this.sprints.old) {
this.sprints.first = 0;
this.sprints.last = Math.min(this.sprints.limit, num[0]);
}
else if (num.length === 1) {
this.sprints.first = 0;
this.sprints.last = Number(num[0]);
}
else if (num.length === 2) {
this.sprints.first = Number(num[0]);
this.sprints.current = Math.max(0, this.sprints.first);
this.sprints.last = Number(num[1]);
}
else {
this.sprints.first = Number(num[0]);
this.sprints.current = Number(num[1]);
this.sprints.last = Number(num[2]);
}
}
/**
* Update which sprint metadata fields to allow the report to use.
*/
updateMeta(meta) {
const selected = OrderedSet(_.intersection(meta,
this.sprint_meta.known
));
if (!selected.equals(this.sprint_meta.selected)) {
this.sprint_meta.selected = selected;
this.sprint_meta.changed = true;
}
}
/**
* Update the visibility of the confiugration panel. The provided argument
* is an arrat where the first element is the toggle state, a string which
* is either '0' to hide the panel or '1' to show it.
*/
updateConfig(visible) {
const toggle = d3.select('#options a[data-toggle=config]');
const d = toggle.datum();
if (d && d.state !== visible[0]) {
this.clickToggle(d3.select('#config'), d.state === '0',
d, toggle
);
d.state = visible[0];
}
}
/**
* Perform additional adjustments after a toggle of the configuration panel.
*/
toggleConfig(toggle, d, show) {
const visible = show ? '0' : '1';
if (visible !== d.state) {
d.state = show ? '1' : '0';
toggle.attr('href', this.getUrl({config: [visible]}));
}
}
/**
* Perform adjustments after a toggle of the source age panel.
*/
toggleSources(show) {
if (show) {
const projects = Array.from(this.projects.selected);
const sources = new SourceAge(this.locales, this.localization);
sources.build(projects);
}
}
/**
* Build the configuration panel and toggles.
*/
makeConfiguration(spinner) {
this.makeToggle();
// Project navigation handles current item selection which builds the
// remaining selections in the configuration.
this.makeProjectNavigation(spinner);
this.makeExportOptions();
}
/**
* Display the title of the report.
* Because a PDF renderer may await until the report is ready, the title
* should only be updated once this is the case.
*/
setTitle() {
const projects = Array.from(this.projects.selected).join(", ");
const title = d3.select("#title");
title.select("span.projects").text(projects !== "" ?
this.locales.message("title-projects", [projects]) : ""
);
}
/**
* Create the toggles of the panels.
*/
makeToggle() {
d3.selectAll('#options .toggle')
.classed('tooltip', true)
.datum(function() {
return this.dataset;
})
.each((d, i, nodes) => {
const hidden = d3.select(`#${d.toggle}`).classed('is-hidden');
const label = this.locales.message(`${d.toggle}-${hidden ? "show" : "hide"}`);
d3.select(nodes[i])
.attr(LABEL_ATTR, label)
.attr(TOOLTIP_ATTR, label);
})
.on('click', (d, i, nodes) => {
const config = d3.select(`#${d.toggle}`);
const hidden = config.classed('is-hidden');
const toggle = d3.select(nodes[i]);
if (this.configToggles[d.toggle]) {
this.configToggles[d.toggle](toggle, d, hidden);
}
this.clickToggle(config, hidden, d, toggle);
});
}
/**
* Handle a click of a toggle. This makes the associated panel visible and
* changes the toggle icon state.
*/
clickToggle(config, hidden, d, toggle) {
const column = d3.select(toggle.node().parentNode.parentNode.parentNode);
column.classed('is-narrow', false).classed('is-11', true);
config.classed('is-hidden', false)
.style('opacity', hidden ? 0 : 1)
.transition()
.style('opacity', hidden ? 1 : 0)
.on("end", function() {
d3.select(this).classed('is-hidden', !hidden);
column.classed('is-narrow', !hidden)
.classed('is-11', hidden);
this.scrollIntoView({
behavior: "smooth",
block: "end"
});
});
toggle.attr('aria-expanded', hidden ? "true" : "false")
.attr(LABEL_ATTR, this.locales.message(`${d.toggle}-${hidden ? "hide" : "show"}`))
.attr(TOOLTIP_ATTR, this.locales.message(`${d.toggle}-${hidden ? "hide" : "show"}`))
.select('i')
.classed(d.shown, hidden)
.classed(d.hidden, !hidden);
}
/**
* Adjust the toggles and potentially their associated panels, possibly
* from navigating to a link.
*/
updateToggle() {
const options = d3.select('#options');
options.selectAll('.toggle').each((d, i, nodes) => {
const config = d3.select(`#${d.toggle}`);
const hidden = _.has(d, 'state') ? d.state === '0' :
config.classed('is-hidden');
if (this.configToggles[d.toggle]) {
this.configToggles[d.toggle](d3.select(nodes[i]), d, !hidden);
}
});
options.classed("is-hidden", false);
}
/**
* Update the selection of projects based on data detailing which projects
* the user should see most prominently. Potentially, this function also
* updates the entire report, if it is to show specific projects based on
* this data.
*/
setAccessible(projects, spinner) {
projects = OrderedSet(projects);
if (projects.isEmpty() || projects.has('*')) {
return;
}
this.projects.meta = _.map(this.projects.meta, project => {
project.accessible = _.isArray(project.project_names) ?
!projects.intersect(project.project_names).isEmpty() :
projects.has(project.project_names);
return project;
});
this.updateProjectNavigation("update");
const prefix = '#project_';
const hash = decodeURIComponent(window.location.hash);
if (hash.startsWith(prefix) && hash.includes("~accessible")) {
spinner.start();
this.setCurrentItem(hash.slice(prefix.length));
this.updateProjects();
this.makeSprintSelection();
this.makeFeatureSelection();
this.makeFormatSelection();
this.makeFormat(spinner);
}
}
/**
* Retrieve details relevant for filtering the project navigation.
*/
getProjectFilter() {
const isRecent = _.every(this.projects.meta,
(project) => this.projects.selected.has(project.name) ?
project.recent : true
);
const teamProjects = new Set(_.flatten(_.map(this.projects.meta,
project => this.projects.selected.has(project.name) &&
_.isArray(project.project_names) ? project.project_names : []
)));
const isTeam = _.every(this.projects.meta,
(project) => teamProjects.has(project.name) || project.team ||
!this.projects.selected.has(project.name)
);
const isAccessible = _.every(this.projects.meta,
(project) => this.projects.selected.has(project.name) ?
project.accessible : true
);
const isSupport = _.every(this.projects.meta, project => !project.core);
// XOR on checked/inverse state of a filter
const includeFilter = (checked, inverse) => (inverse && !checked) ||
(!inverse && checked);
const filter = (projects) => {
const filters = [];
d3.selectAll('#project-filter input').each(function(d) {
const checked = d3.select(this).property('checked');
if (includeFilter(checked, d.inverse)) {
filters.push(d.inverse || d.key);
}
});
return _.filter(projects,
project => _.every(filters, filter => !!project[filter])
);
};
return {isRecent, isSupport, isTeam, isAccessible, filter};
}
/**
* Create checkboxes that control which types of projects are displayed
* within the project navigation.
*/
buildProjectFilter(projectNavigation, selections) {
const {isRecent, isSupport, isTeam, isAccessible, filter} =
this.getProjectFilter();
const projectFilter = () => _.concat(selections,
filter(this.projects.meta)
);
const filters = [
{key: 'recent', default: !!isRecent},
{key: 'support', inverse: 'core', default: !!isSupport},
{key: 'team', default: !!isTeam}
];
if (config.access_url !== "") {
filters.push({key: 'accessible', default: !!isAccessible});
}
const label = d3.select('#project-filter')
.selectAll('label')
.data(filters)
.enter()
.append('label')
.classed('checkbox tooltip', true)
.attr(TOOLTIP_ATTR, d => this.locales.attribute("project-filter-title", d.key));
label.append('input')
.attr('type', 'checkbox')
.property('checked', d => d.default)
.on('change', () => projectNavigation.update(projectFilter()));
label.append('span')
.text(d => this.locales.attribute("project-filter", d.key));
return projectFilter;
}
/**
* Determine the list of projects associated with a team, but only if
* those projects are visible in the project navigation.
*/
includeTeamProjects(updateList, d) {
if (!_.isArray(d.project_names)) {
return [];
}
const listNames = _.map(updateList.data(), p => p.name);
const shown = _.intersection(listNames, d.project_names);
if (_.isEmpty(shown) || (shown.length === 1 && shown[0] === d.name)) {
return [];
}
return shown;
}
/**
* Determine which projects a shorthand refers to.
*
* Valid shorthands are:
* - '~all': All known projects.
* - '~team': All recent projects that are actually teams.
* - '~accessible': Recent projects that are actually teams and that should
* be shown prominently to the user.
* - '~recent': All recent projects that are not support teams.
* - '~support': All support team projects.
*/
convertProject(project) {
const groups = {
"~all": () => this.projects.known,
"~team": () => _.map(
_.filter(this.projects.meta, d => d.recent && d.team), "name"
),
"~accessible": () => _.map(_.filter(this.projects.meta,
d => d.recent && d.accessible && d.team
), "name"),
"~recent": () => _.map(
_.filter(this.projects.meta, d => d.core && d.recent), "name"
),
"~support": () => _.map(
_.filter(this.projects.meta, d => !d.core), "name"
)
};
return groups[project] ? groups[project]() : project;
}
/**
* Update what items should be shown on the report page, based on a string
* which may start with several project keys, separated by ',' or '&'
* characters, as well as other fields which are separated from each other
* and from each other by '!' or '|' characters and the name of those fields
* is separated from the new selection of values by an underscore, while
* that field's selection uses the ',' or '&' delimiter as well.
*/
setCurrentItem(project) {
const parts = project.split(/[!|]/);
_.forEach(parts, (value, index) => {
if (index === 0) {
const known = new Set(this.projects.known);
const names = _.flatten(_.map(value.split(/[,&]/),
name => this.convertProject(name)
));
this.projects.selected = OrderedSet(names).intersect(known);
this.projects.visible =
this.projects.selected.subtract(this.projects.invisible);
}
else {
const sep = value.indexOf('_');
const name = value.substr(0, sep);
const values = value.substr(sep + 1).split(/[,&]/);
if (this.navigationHooks[name]) {
this.navigationHooks[name](values);
}
}
});
}
/**
* Check whether the project key is selected.
*/
isProjectActive(key) {
return this.projects.selected.includes(key);
}
/**
* Retrieve a tooltip to display for a project within the navigation.
*/
getProjectTooltip(d, updateList) {
if (d.title) {
return d.title;
}
let prefix = "project";
if (_.isArray(d.project_names)) {
prefix += "-team";
if (_.isEmpty(this.includeTeamProjects(updateList, d))) {
prefix += "-only";
}
}
else if (d.component) {
prefix += "-component";
}
const msg = `${prefix}-title-${this.isProjectActive(d.name) ? "remove" : "add"}`;
return this.locales.message(msg, [_.isNil(d.quality_display_name) ?
d.name : d.quality_display_name
]);
}
/**
* Update the project navigation.
*
* The `method` must be a valid `Navigation` function to use, i.e., `start`
* or `update`.
*/
updateProjectNavigation(method) {
if (this.projectNavigation === null) {
return;
}
const projectFilter = this.buildProjectFilter(this.projectNavigation, [
{
name: "*",
display_name: null,
message: this.locales.message("projects-select-all"),
title: this.locales.message("projects-select-all-title"),
projects: (list) => OrderedSet(_.map(
_.filter(list.data(), project => !project.projects),
project => project.name
)),
classes: "is-link"
},
{
name: "",
display_name: null,
message: this.locales.message("projects-deselect"),
title: this.locales.message("projects-deselect-title"),
projects: () => OrderedSet(),
classes: "is-danger"
}
]);
this.projectNavigation[method](projectFilter());
}
/**
* Update the projects which are selected in the project navigation based on
* a link navigation, for example.
*/
updateProjects(element=null, list=null) {
if (list === null) {
list = d3.selectAll('#navigation ul li');
}
if (element === null) {
element = list.selectAll('a');
}
const updateList = list.merge(list.enter());
element.each((d, i, nodes) => {
d3.select(nodes[i].parentNode)
.classed('is-active', d => this.isProjectActive(d.name));
});
element.attr('class', d => `tooltip has-tooltip-multiline has-tooltip-center ${d.classes || ""}`)
.attr(TOOLTIP_ATTR, d => this.getProjectTooltip(d, updateList))
.attr('href', d => {
let projects = null;
if (d.projects) {
projects = d.projects(updateList);
}
else {
let teamProjects = this.includeTeamProjects(updateList, d);
projects = setToggle(OrderedSet(this.projects.selected),
d.name, _.isEmpty(teamProjects) ? null : teamProjects
);
}
return this.getUrl({project: projects});
});
element.select('span.project').text(d => d.message || d.name);
updateOrderTags(element, this.projects, d => d.name);
}
/**
* Build the project navigation as well as the remainder of the report page.
*/
makeProjectNavigation(spinner) {
// Create project navigation
const updateSelection = () => {
this.updateProjects();
// Update based on selection changes
this.updateToggle();
this.makeSprintSelection();
this.makeFeatureSelection();
this.makeFormatSelection();
this.makeFormat(spinner);
};
this.projectNavigation = new Navigation({
container: '#navigation',
prefix: 'project_',
key: d => d.name,
isActive: key => this.isProjectActive(key),
setCurrentItem: (project, hasProject) => {
this.setCurrentItem(project);
updateSelection();
return true;
},
addElement: (element) => {
element.append('span').classed('project', true);
element.append('span').classed('tag', true);
this.updateProjects(element);
element.style("width", "0%")
.style("opacity", "0")
.transition()
.style("width", "100%")
.style("opacity", "1");
this.addDrag(element, {
name: "project",
items: this.projects,
key: d => d.name
});
},
updateElement: (element) => {
this.updateProjects(element.selectAll('a'), element);
},
removeElement: (element) => {
element.transition()
.style("opacity", "0")
.remove();
}
});
// Select projects to let the filter know if we selected a project that
// would be filtered by default.
const prefix = '#project_';
const hash = decodeURIComponent(window.location.hash);
if (hash.startsWith(prefix)) {
this.setCurrentItem(hash.slice(prefix.length));
}
this.updateProjectNavigation("start");
}
/**
* Update a project navigation element to a valid drag-and-drop element for
* reordering.
*/
addDrag(element, config) {
addDrag(this.getState(), element, config);
}
/**
* Create or update the sprint selection in the configuration panel.
*/
makeSprintSelection() {
const selection = new SprintSelection(this.getState(),
this.localization, this.locales
);
selection.makeSprintBrush();
selection.makeSprintSelect();
selection.makeSprintFilter();
selection.makeSprintMeta();
}
/**
* Create or update the feature selection in the configuration panel.
*/
makeFeatureSelection() {
const selection = new FeatureSelection(this.getState(),
this.localization, this.locales
);
selection.makeFeatureCategories();
selection.makeSelectedFeatures();
}
/**
* Generate an expression for the feature with localized attribute names.
*/
getAssignment(feature, locales=["descriptions"], values=null) {
const assignment = this.features.expressions[feature];
if (!assignment || !assignment.expression) {
return null;
}
if (!assignment.attributes) {
return assignment.expression;
}
return _.replace(assignment.expression,
new RegExp(`(^|\\W)(${_.join(assignment.attributes, '|')})(\\W|$)`, "g"),
(m, p1, attribute, p2) => {
let unit = _.transform(locales, (accumulator, key) => {
const locale = this.localization[key];
const text = this.locales.retrieve(locale,
attribute, null
);
if (text !== null) {
accumulator.text = text;
return false;
}
return null;
}, {text: "%s"}).text;
if (values !== null) {
const value = values[attribute];
if (this.formatter.current !== null) {
unit = this.formatter.current
.formatUnitText(attribute, value, unit);
}
else {
unit = vsprintf(unit, [value]);
}
}
return `${p1 || ""}${unit}${p2 || ""}`;
}
);
}
/**
* Create or update the format selection in the configuration panel.
*/
makeFormatSelection() {
const selection = new FormatSelection(this.getState(), this.formatter,
this.locales
);
selection.makeFormats();
}
/**
* Retrieve the current selection state.
*/
getState() {
return {
projects: this.projects,
features: this.features,
sprints: this.sprints,
sprint_meta: this.sprint_meta,
formatter: this.formatter
};
}
/**
* Create or update the report with the selected format.
*/
makeFormat(spinner) {
if (this.formatter.current === null) {
this.updateFormat(this.formatter.selected);
}
this.formatter.current.build(this.getState(), spinner)
.then((data) => {
this.formatter.data = data;
this.setTitle();
});
}
/**
* Create or update the export panel.
*/
makeExportOptions() {
const button = d3.select('#export')
.selectAll('button')
.data(this.exporter.known)
.enter()
.append('button')
.classed('button is-outlined is-light has-text-grey-darker tooltip has-tooltip-bottom', true)
.attr(TOOLTIP_ATTR, d => this.locales.attribute('export_tooltip', d.name))
.attr('id', d => `export-${d.name}`);
button.append('span')
.classed('icon', true)
.append('i')
.attr('class', d => d.icon.join(' '));
button.append('span')
.text(d => this.locales.attribute('exports', d.name));
button.on('click', (d, i, nodes) => {
const activeButton = d3.select(nodes[i])
.classed('is-loading', true);
const exporter = new this.exporter.classes[d.name](this.locales,
this.localization, this.getState()
);
exporter.setCurrentUrl(this.getUrl(exporter.getUrlSelection()));
exporter.build(activeButton, this.formatter.data);
});
}
}
export default Builder;