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 (
@@ -84,11 +100,12 @@ class ConfigComponent extends React.Component { resolution={'weeks'} xAxisCenter={true} content={activityTimeline && activityTimeline.length > 0 ? activityTimeline : [{ date: 0, activity: 0 }]} - d3offset={d3.stackOffsetDiverging} + d3offset={stackOffsetDiverging} yDims={_.values(yDims)} onDimensionsRestricted={dims => onActivityDimensionsRestricted(dims)} />
+ 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] }; }); },