diff --git a/ui/src/visualizations/team-awareness/components/FileSelection/FileSelection.js b/ui/src/visualizations/team-awareness/components/FileSelection/FileSelection.js
new file mode 100644
index 00000000..e717e90d
--- /dev/null
+++ b/ui/src/visualizations/team-awareness/components/FileSelection/FileSelection.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import styles from './FileSelection.scss';
+import _ from 'lodash';
+import { checkAllChildrenSelected } from '../../sagas/fileTreeOperations';
+
+export default class FileSelection extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ const { onSetFilteredFile, onSetFileFilterMode, files } = this.props;
+
+ return (
+
+
+
+
+
+ {_.map(files, (file, i) => onSetFilteredFile(f)} />)}
+
+
+ );
+ }
+}
+
+class FileTreeNode extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ expanded: false
+ };
+ }
+
+ render() {
+ const { node, level, onSetFilteredFile } = this.props;
+ const hasChildren = node.children && node.children.length > 0;
+ const folderIcon = this.state.expanded ? 'fa-folder-open' : 'fa-folder';
+
+ return (
+ ((node.type === 'folder' && hasChildren) || node.type === 'file') &&
+
+
+ {hasChildren &&
+ this.state.expanded &&
+
+ {_.map(node.children, (child, i) =>
+ onSetFilteredFile(f)} level={level + 1} node={child} />
+ )}
+
}
+
+ );
+ }
+}
+
+const FolderContent = props => {
+ return (
+ props.onClick()}>
+
+ {props.name}
+
+
+ );
+};
+
+const FileContent = props => {
+ return (
+
+
+ {props.name}
+
+ );
+};
diff --git a/ui/src/visualizations/team-awareness/components/FileSelection/FileSelection.scss b/ui/src/visualizations/team-awareness/components/FileSelection/FileSelection.scss
new file mode 100644
index 00000000..dcd56e27
--- /dev/null
+++ b/ui/src/visualizations/team-awareness/components/FileSelection/FileSelection.scss
@@ -0,0 +1,73 @@
+@import '~bulma';
+
+.fileSelection {
+ overflow-y: scroll;
+ max-height: 35vh;
+}
+
+
+
+
+.treeNode {
+ padding: 0.25rem;
+ width: 100%;
+ text-align: left;
+ outline: none;
+ background: #EEEEEE;
+ border-radius: 4px;
+ border: 1px solid white;
+
+ span {
+ color: #183153;
+ }
+}
+
+.nodeHeader {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+}
+
+.folderCaret {
+ margin-left: auto;
+ margin-right: 5px;
+}
+
+.nodeHeaderContent {
+ width: 100%;
+ display: flex;
+ align-items: center;
+}
+
+.nodeIcon {
+ margin-right: 5px;
+}
+
+.modeButton {
+ padding: 0.20rem;
+ min-width: 3rem;
+ border: none;
+ cursor: pointer;
+}
+
+.fileTreeNode {
+
+}
+
+.folderTreeNode {
+ cursor: pointer;
+ // Make background a bit lighter
+ background: #2596be;
+}
+
+
+.subLevel {
+ margin-left: 0.5rem;
+ padding-left: 0.5rem;
+ border-left: 0.1rem solid #CCCCCC;
+}
+
+.rootLevel{
+
+}
diff --git a/ui/src/visualizations/team-awareness/config.js b/ui/src/visualizations/team-awareness/config.js
index 18ca680d..cce4e52b 100644
--- a/ui/src/visualizations/team-awareness/config.js
+++ b/ui/src/visualizations/team-awareness/config.js
@@ -2,21 +2,25 @@
import React from 'react';
import { connect } from 'react-redux';
-import { setActivityDimensions, setActivityScale, setBranch } from './sagas';
+import { setActivityDimensions, setActivityScale, setBranch, setFileFilterMode, setFilteredFiles } from './sagas';
import * as d3 from 'd3';
import { getState } from './util/util';
import _ from 'lodash';
import ActivityTimeline from './components/Timeline/ActivityTimeline';
import styles from './styles.scss';
+import FileSelection from './components/FileSelection/FileSelection';
const mapStateToProps = (appState /*, ownProps*/) => {
const { config, data } = getState(appState);
+
return {
config: {
selectedActivityScale: config.selectedActivityScale,
selectedBranch: config.selectedBranch
},
data: {
+ files: data.data.files,
+ fileTree: data.data.fileTree,
branches: data.data.branches,
activityTimeline: data.data.activityTimeline,
yDims: data.data.dataBoundaries
@@ -24,10 +28,13 @@ const mapStateToProps = (appState /*, ownProps*/) => {
};
};
const mapDispatchToProps = dispatch => {
+ // noinspection JSUnusedGlobalSymbols
return {
- onSelectActivityScale: selectActivity => dispatch(setActivityScale(selectActivity)),
- onActivityDimensionsRestricted: restrictActivity => dispatch(setActivityDimensions(restrictActivity)),
- onSelectBranch: selectActivity => dispatch(setBranch(selectActivity))
+ onSelectActivityScale: activity => dispatch(setActivityScale(activity)),
+ onActivityDimensionsRestricted: activity => dispatch(setActivityDimensions(activity)),
+ onSelectBranch: activity => dispatch(setBranch(activity)),
+ onSetFilteredFile: activity => dispatch(setFilteredFiles(activity)),
+ onSetFileFilterMode: activity => dispatch(setFileFilterMode(activity))
};
};
@@ -37,9 +44,18 @@ class ConfigComponent extends React.Component {
}
render() {
- const { onActivityDimensionsRestricted, onSelectActivityScale, config, onSelectBranch } = this.props;
- const { activityTimeline, yDims, branches } = this.props.data;
- console.log(activityTimeline);
+ const {
+ onActivityDimensionsRestricted,
+ onSelectActivityScale,
+ config,
+ data,
+ onSelectBranch,
+ onSetFilteredFile,
+ onSetFileFilterMode
+ } = this.props;
+ const { activityTimeline, yDims, branches, fileTree } = data;
+ const { stackOffsetDiverging } = d3;
+
return (
+ onSetFileFilterMode(m)} files={fileTree} onSetFilteredFile={f => onSetFilteredFile(f)} />
);
}
diff --git a/ui/src/visualizations/team-awareness/reducers/config.js b/ui/src/visualizations/team-awareness/reducers/config.js
index ff953aeb..557cea9b 100644
--- a/ui/src/visualizations/team-awareness/reducers/config.js
+++ b/ui/src/visualizations/team-awareness/reducers/config.js
@@ -2,14 +2,37 @@
import { handleActions } from 'redux-actions';
import _ from 'lodash';
+import { flattenNode } from '../sagas/fileTreeOperations';
export default handleActions(
{
SET_TEAM_AWARENESS_ACTIVITY_SCALE: (state, action) => _.assign({}, state, { selectedActivityScale: action.payload }),
SET_TEAM_AWARENESS_ACTIVITY_DIMENSIONS: (state, action) => _.assign({}, state, action.payload),
- SET_TEAM_AWARENESS_BRANCH: (state, action) => _.assign({}, state, { selectedBranch: action.payload })
+ SET_TEAM_AWARENESS_BRANCH: (state, action) => _.assign({}, state, { selectedBranch: action.payload }),
+ SET_TEAM_AWARENESS_FILTERED_FILES: (state, action) => {
+ const nextFilter = new Map();
+ state.fileFilter.files.forEach(f => nextFilter.set(f.id, f));
+
+ const addToMap = file => nextFilter.set(file.id, file);
+ const deleteFromMap = file => nextFilter.delete(file.id);
+
+ let operation = state.fileFilter.mode === 'EXCLUDE' && action.payload.selected === false ? addToMap : deleteFromMap;
+ if (state.fileFilter.mode === 'INCLUDE') {
+ operation = action.payload.selected === true ? addToMap : deleteFromMap;
+ }
+
+ flattenNode(action.payload.node).forEach(n => operation(n));
+ return _.assign({}, state, {
+ fileFilter: _.assign({}, state.fileFilter, { files: Array.from(nextFilter.values()) })
+ });
+ },
+ SET_TEAM_AWARENESS_FILE_FILTER_MODE: (state, action) => _.assign({}, state, { fileFilter: { mode: action.payload, files: [] } })
},
{
+ fileFilter: {
+ mode: 'EXCLUDE',
+ files: []
+ },
selectedBranch: 'all',
selectedActivityScale: 'commits',
activityRestricted: false,
diff --git a/ui/src/visualizations/team-awareness/reducers/data.js b/ui/src/visualizations/team-awareness/reducers/data.js
index 3df4a6d6..52345f6c 100644
--- a/ui/src/visualizations/team-awareness/reducers/data.js
+++ b/ui/src/visualizations/team-awareness/reducers/data.js
@@ -5,9 +5,7 @@ import { handleActions } from 'redux-actions';
export default handleActions(
{
- REQUEST_TEAM_AWARENESS_DATA: state => {
- return _.assign({}, state, { isFetching: true });
- },
+ REQUEST_TEAM_AWARENESS_DATA: state => _.assign({}, state, { isFetching: true }),
RECEIVE_TEAM_AWARENESS_DATA: (state, action) => {
return _.assign({}, state, {
data: action.payload,
@@ -16,20 +14,13 @@ export default handleActions(
receivedAt: action.meta.receivedAt
});
},
- PROCESS_TEAM_AWARENESS_DATA: (state, action) => {
- return _.assign({}, state, {
- data: {
- branches: state.data.branches,
- commits: state.data.commits,
- stakeholders: action.payload.stakeholders,
- activityTimeline: action.payload.activityTimeline,
- dataBoundaries: action.payload.dataBoundaries
- }
- });
- }
+ PROCESS_TEAM_AWARENESS_DATA: (state, action) => _.assign({}, state, { data: _.assign({}, state.data, action.payload) }),
+ PROCESS_TEAM_AWARENESS_FILE_BROWSER: (state, action) => _.assign({}, state, { data: _.assign({}, state.data, action.payload) })
},
{
data: {
+ files: [],
+ fileTree: [],
branches: [],
commits: [],
stakeholders: []
diff --git a/ui/src/visualizations/team-awareness/sagas/calculateFigures.js b/ui/src/visualizations/team-awareness/sagas/calculateFigures.js
index 815083c5..3d79a518 100644
--- a/ui/src/visualizations/team-awareness/sagas/calculateFigures.js
+++ b/ui/src/visualizations/team-awareness/sagas/calculateFigures.js
@@ -1,6 +1,9 @@
+'use strict';
+
import { put, select } from 'redux-saga/effects';
import { processTeamAwarenessData } from './index';
import { getState } from '../util/util';
+import _ from 'lodash';
export default function*() {
const appState = yield select();
@@ -8,7 +11,7 @@ export default function*() {
}
function processData(appState) {
- const vizState = getState(appState);
+ const { config, data } = getState(appState);
/** @type {Map} */
const stakeholders = new Map();
@@ -21,19 +24,18 @@ function processData(appState) {
max: Number.MIN_SAFE_INTEGER
};
- let activityCalculator = selectCalculationFunction(vizState.config);
- console.log(vizState.data.data);
- if (vizState.config.activityRestricted === true) {
- const from = Date.parse(vizState.config.activityDims[0]);
- const to = Date.parse(vizState.config.activityDims[1]);
+ let activityCalculator = filterCommitOnFiles(config.fileFilter, selectCalculationFunction(config));
+ if (config.activityRestricted === true) {
+ const from = Date.parse(config.activityDims[0]);
+ const to = Date.parse(config.activityDims[1]);
activityCalculator = filterCommitOnDate(from, to, activityCalculator);
}
- if (vizState.config.selectedBranch && vizState.config.selectedBranch !== 'all') {
- activityCalculator = filterCommitOnBranch(vizState.config.selectedBranch, activityCalculator);
+ if (config.selectedBranch && config.selectedBranch !== 'all') {
+ activityCalculator = filterCommitOnBranch(config.selectedBranch, activityCalculator);
}
- vizState.data.data.commits.forEach(c => {
+ data.data.commits.forEach(c => {
const calculatedActivity = activityCalculator(c);
if (calculatedActivity !== 0) {
if (!stakeholders.has(c.stakeholder.id)) {
@@ -109,6 +111,29 @@ function filterCommitOnBranch(selectedBranch, fn) {
};
}
+/**
+ * @param filter
+ * @param fn {function}
+ * @return {function(*): number}
+ */
+function filterCommitOnFiles(filter, fn) {
+ const filteredCommits = _.reduce(
+ filter.files,
+ (acc, f) => {
+ _.map(f.commits.data, 'sha').forEach(s => acc.add(s));
+ return acc;
+ },
+ new Set()
+ );
+
+ let excludeFilesFn = sha => !filteredCommits.has(sha);
+ if (filter.mode === 'INCLUDE') {
+ excludeFilesFn = sha => filteredCommits.has(sha);
+ }
+
+ return commit => (excludeFilesFn(commit.sha) ? fn(commit) : 0);
+}
+
function selectCalculationFunction(config) {
const { selectedActivityScale } = config;
if (!selectedActivityScale) {
@@ -117,7 +142,7 @@ function selectCalculationFunction(config) {
switch (selectedActivityScale) {
case 'activity':
- return commit => commit.stats.additions + commit.stats.deletions;
+ return commit => commit.stats.additions - commit.stats.deletions;
case 'additions':
return commit => commit.stats.additions;
case 'deletions':
diff --git a/ui/src/visualizations/team-awareness/sagas/fileTreeOperations.js b/ui/src/visualizations/team-awareness/sagas/fileTreeOperations.js
new file mode 100644
index 00000000..6cdda3d1
--- /dev/null
+++ b/ui/src/visualizations/team-awareness/sagas/fileTreeOperations.js
@@ -0,0 +1,140 @@
+'use strict';
+import _ from 'lodash';
+import { processTeamAwarenessFileBrowser } from './index';
+import { put, select } from 'redux-saga/effects';
+import { getState } from '../util/util';
+
+const filterNullValues = node => {
+ const childMapToArray = n => {
+ if (!n.children) {
+ return n;
+ }
+ n.children = filterNullValues(n.children);
+ if (n.children.length === 0) {
+ return null;
+ }
+ return n;
+ };
+
+ return _.reduce(
+ [...node.values()],
+ (collector, current) => {
+ const child = childMapToArray(current);
+ if (child) {
+ collector.push(child);
+ }
+ return collector;
+ },
+ []
+ );
+};
+
+const checkAllChildrenSelected = node => {
+ if (node.type === 'file') {
+ return node.file.selected;
+ }
+ for (const child of node.children) {
+ if (checkAllChildrenSelected(child) === false) {
+ return false;
+ }
+ }
+ return true;
+};
+
+const flattenNode = node => {
+ if (node.type === 'file') {
+ return Array.of(node.file);
+ }
+
+ return _.reduce(
+ node.children,
+ (collector, current) => {
+ collector.push(...flattenNode(current));
+ return collector;
+ },
+ []
+ );
+};
+
+const buildNodeStep = (file, pathParts, pathIndex, node, filterFn, fileSelectedFn) => {
+ if (pathParts.length === pathIndex + 1) {
+ if (!filterFn(file)) {
+ file.selected = fileSelectedFn(file);
+ node.set(pathParts[pathIndex], {
+ name: pathParts[pathIndex],
+ type: 'file',
+ path: _.join(pathParts.slice(0, pathIndex + 1), '/'),
+ file
+ });
+ }
+ return;
+ }
+
+ if (!node.has(pathParts[pathIndex])) {
+ node.set(pathParts[pathIndex], {
+ name: pathParts[pathIndex],
+ type: 'folder',
+ path: _.join(pathParts.slice(0, pathIndex + 1), '/'),
+ children: new Map()
+ });
+ }
+
+ buildNodeStep(file, pathParts, pathIndex + 1, node.get(pathParts[pathIndex]).children, filterFn, fileSelectedFn);
+};
+
+const fileCommitDateInRange = (from, to, commit) => {
+ const date = Date.parse(commit.date);
+ return from <= date && date <= to;
+};
+
+const filterFileTreeOnDate = (from, to, next) => {
+ return (file, data) => {
+ if (data && data.branchCommit) {
+ return _.filter(data.branchCommit, c => fileCommitDateInRange(from, to, c)).length === 0;
+ }
+ return _.filter(file.commits.data, c => fileCommitDateInRange(from, to, c)).length === 0 ? true : next(file, data);
+ };
+};
+
+const filterFileTreeOnBranch = (branch, next) => {
+ return file => {
+ const branchCommit = _.filter(file.commits.data, { branch });
+ return branchCommit.length === 0 ? true : next(file, { branchCommit });
+ };
+};
+
+function* generateFileBrowser() {
+ const appState = yield select();
+ yield put(processTeamAwarenessFileBrowser(constructFromAppState(appState)));
+}
+
+const constructFromAppState = appState => {
+ const { config, data } = getState(appState);
+
+ let nodeFilterFn = () => false;
+ if (config.activityRestricted === true) {
+ const from = Date.parse(config.activityDims[0]);
+ const to = Date.parse(config.activityDims[1]);
+ nodeFilterFn = filterFileTreeOnDate(from, to, nodeFilterFn);
+ }
+
+ if (config.selectedBranch && config.selectedBranch !== 'all') {
+ // noinspection JSValidateTypes
+ nodeFilterFn = filterFileTreeOnBranch(config.selectedBranch, nodeFilterFn);
+ }
+ // noinspection JSCheckFunctionSignatures
+ let fileSelectedFn = file => undefined === _.find(config.fileFilter.files, { path: file.path });
+ if (config.fileFilter.mode === 'INCLUDE') {
+ // noinspection JSCheckFunctionSignatures
+ fileSelectedFn = file => _.find(config.fileFilter.files, { path: file.path }) !== undefined;
+ }
+
+ const treeNode = new Map();
+ data.data.files.forEach(file => buildNodeStep(file, file.path.split('/'), 0, treeNode, nodeFilterFn, fileSelectedFn));
+
+ return {
+ fileTree: filterNullValues(treeNode)
+ };
+};
+
+export { filterFileTreeOnBranch, filterFileTreeOnDate, checkAllChildrenSelected, flattenNode, generateFileBrowser };
diff --git a/ui/src/visualizations/team-awareness/sagas/getCommits.js b/ui/src/visualizations/team-awareness/sagas/getCommits.js
index e075d1e8..f996ee83 100644
--- a/ui/src/visualizations/team-awareness/sagas/getCommits.js
+++ b/ui/src/visualizations/team-awareness/sagas/getCommits.js
@@ -15,6 +15,7 @@ const getCommits = (page, perPage) => {
query($page:Int, $perPage:Int) {
commits(page:$page, perPage:$perPage) {
data {
+ sha
date
branch
stakeholder {
diff --git a/ui/src/visualizations/team-awareness/sagas/getFiles.js b/ui/src/visualizations/team-awareness/sagas/getFiles.js
new file mode 100644
index 00000000..4f5bb2f9
--- /dev/null
+++ b/ui/src/visualizations/team-awareness/sagas/getFiles.js
@@ -0,0 +1,34 @@
+import { graphQl, traversePages } from '../../../utils';
+
+/**
+ * Fetches commit data via GraphQL API from the data source.
+ */
+export default () => {
+ const buildList = [];
+ return traversePages(getFiles, build => buildList.push(build)).then(() => buildList);
+};
+
+const getFiles = (page, perPage) => {
+ return graphQl
+ .query(
+ `
+ query($page:Int,$perPage:Int) {
+ files(page:$page, perPage:$perPage) {
+ data {
+ id
+ path
+ commits {
+ data {
+ date
+ branch
+ sha
+ }
+ }
+ }
+ }
+ }`,
+ page,
+ perPage
+ )
+ .then(result => result.files);
+};
diff --git a/ui/src/visualizations/team-awareness/sagas/index.js b/ui/src/visualizations/team-awareness/sagas/index.js
index 1b621692..aa54dda0 100644
--- a/ui/src/visualizations/team-awareness/sagas/index.js
+++ b/ui/src/visualizations/team-awareness/sagas/index.js
@@ -4,14 +4,19 @@ import { createAction } from 'redux-actions';
import { fork, takeEvery, throttle } from 'redux-saga/effects';
import { fetchFactory, mapSaga, timestampedActionFactory } from '../../../sagas/utils';
import getCommits from './getCommits';
-import recalculate from './calculateFigures';
+import calculateGraphFigures from './calculateFigures';
import getBranches from './getBranches';
+import getFiles from './getFiles';
+import { generateFileBrowser } from './fileTreeOperations';
export const setActivityScale = createAction('SET_TEAM_AWARENESS_ACTIVITY_SCALE');
export const setActivityDimensions = createAction('SET_TEAM_AWARENESS_ACTIVITY_DIMENSIONS');
export const setBranch = createAction('SET_TEAM_AWARENESS_BRANCH');
+export const setFilteredFiles = createAction('SET_TEAM_AWARENESS_FILTERED_FILES');
+export const setFileFilterMode = createAction('SET_TEAM_AWARENESS_FILE_FILTER_MODE');
export const processTeamAwarenessData = timestampedActionFactory('PROCESS_TEAM_AWARENESS_DATA');
+export const processTeamAwarenessFileBrowser = timestampedActionFactory('PROCESS_TEAM_AWARENESS_FILE_BROWSER');
export const requestTeamAwarenessData = createAction('REQUEST_TEAM_AWARENESS_DATA');
export const receiveTeamAwarenessData = timestampedActionFactory('RECEIVE_TEAM_AWARENESS_DATA');
export const receiveTeamAwarenessDataError = timestampedActionFactory('RECEIVE_TEAM_AWARENESS_DATA_ERROR');
@@ -20,10 +25,12 @@ export const requestRefresh = createAction('REQUEST_REFRESH');
const refresh = createAction('REFRESH');
export default function*() {
- yield fork(watchDataReceive);
- yield fork(watchActivityScaleSet);
- yield fork(watchBranchSet);
- yield fork(watchActivityDimensionsSet);
+ yield fork(invokeAllDateGenerators('RECEIVE_TEAM_AWARENESS_DATA'));
+ yield fork(invokeAllDateGenerators('SET_TEAM_AWARENESS_ACTIVITY_SCALE'));
+ yield fork(invokeAllDateGenerators('SET_TEAM_AWARENESS_BRANCH'));
+ yield fork(invokeAllDateGenerators('SET_TEAM_AWARENESS_ACTIVITY_DIMENSIONS'));
+ yield fork(invokeAllDateGenerators('SET_TEAM_AWARENESS_FILTERED_FILES'));
+ yield fork(invokeAllDateGenerators('SET_TEAM_AWARENESS_FILE_FILTER_MODE'));
yield fork(watchRefreshRequests);
yield fork(watchMessages);
yield fork(watchRefresh);
@@ -31,21 +38,12 @@ export default function*() {
yield* fetchAwarenessData();
}
-function* watchDataReceive() {
- yield takeEvery('RECEIVE_TEAM_AWARENESS_DATA', recalculate);
-}
-
-function* watchActivityScaleSet() {
- yield takeEvery('SET_TEAM_AWARENESS_ACTIVITY_SCALE', recalculate);
-}
-
-function* watchBranchSet() {
- yield takeEvery('SET_TEAM_AWARENESS_BRANCH', recalculate);
-}
-
-function* watchActivityDimensionsSet() {
- yield takeEvery('SET_TEAM_AWARENESS_ACTIVITY_DIMENSIONS', recalculate);
-}
+const invokeAllDateGenerators = action => {
+ return function*() {
+ yield takeEvery(action, calculateGraphFigures);
+ yield takeEvery(action, generateFileBrowser);
+ };
+};
function* watchRefreshRequests() {
yield throttle(2000, 'REQUEST_REFRESH', mapSaga(refresh));
@@ -62,11 +60,11 @@ function* watchMessages() {
export const fetchAwarenessData = fetchFactory(
function*() {
//const state = getState(yield select());
- return yield Promise.all([getCommits(), getBranches()]).then(result => {
- console.log(result);
+ return yield Promise.all([getCommits(), getBranches(), getFiles()]).then(result => {
return {
commits: result[0],
- branches: result[1]
+ branches: result[1],
+ files: result[2]
};
});
},