From b44396337574b6849a5642e60edfb3fbee2cf85e Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Fri, 17 Feb 2017 10:01:31 -0500 Subject: [PATCH] reports: Allow drilling down --- accounts_lua.go | 1 - balance_lua.go | 1 - date_lua.go | 1 - js/actions/ErrorActions.js | 12 +++++ js/actions/ReportActions.js | 67 +++++++++++++++++++++-- js/components/ReportsTab.js | 70 ++++++++++++++++++++++-- js/components/StackedBarChart.js | 81 +++++++++++++++------------- js/constants/ReportConstants.js | 4 +- js/containers/ReportsTabContainer.js | 6 ++- js/models.js | 35 ++++++------ js/reducers/MoneyGoReducer.js | 2 + js/reducers/SelectedReportReducer.js | 23 ++++++++ reports.go | 4 +- reports_lua.go | 20 +++---- securities_lua.go | 1 - static/css/reports.css | 4 +- 16 files changed, 250 insertions(+), 82 deletions(-) create mode 100644 js/reducers/SelectedReportReducer.js diff --git a/accounts_lua.go b/accounts_lua.go index 4ab1c07..016a47a 100644 --- a/accounts_lua.go +++ b/accounts_lua.go @@ -55,7 +55,6 @@ func luaGetAccounts(L *lua.LState) int { return 1 } -// Registers my account type to given L. func luaRegisterAccounts(L *lua.LState) { mt := L.NewTypeMetatable(luaAccountTypeName) L.SetGlobal("account", mt) diff --git a/balance_lua.go b/balance_lua.go index 220edb0..8e9ae8b 100644 --- a/balance_lua.go +++ b/balance_lua.go @@ -12,7 +12,6 @@ type Balance struct { const luaBalanceTypeName = "balance" -// Registers my balance type to given L. func luaRegisterBalances(L *lua.LState) { mt := L.NewTypeMetatable(luaBalanceTypeName) L.SetGlobal("balance", mt) diff --git a/date_lua.go b/date_lua.go index 1f7d2a6..437ae7b 100644 --- a/date_lua.go +++ b/date_lua.go @@ -8,7 +8,6 @@ import ( const luaDateTypeName = "date" const timeFormat = "2006-01-02" -// Registers my date type to given L. func luaRegisterDates(L *lua.LState) { mt := L.NewTypeMetatable(luaDateTypeName) L.SetGlobal("date", mt) diff --git a/js/actions/ErrorActions.js b/js/actions/ErrorActions.js index 84532a9..289a14c 100644 --- a/js/actions/ErrorActions.js +++ b/js/actions/ErrorActions.js @@ -21,6 +21,17 @@ function ajaxError(error) { }; } +function clientError(error) { + var e = new Error(); + e.ErrorId = 999; + e.ErrorString = "Client Error: " + error; + + return { + type: ErrorConstants.ERROR_CLIENT, + error: e + }; +} + function clearError() { return { type: ErrorConstants.CLEAR_ERROR, @@ -30,5 +41,6 @@ function clearError() { module.exports = { serverError: serverError, ajaxError: ajaxError, + clientError: clientError, clearError: clearError }; diff --git a/js/actions/ReportActions.js b/js/actions/ReportActions.js index d774a6b..725e21b 100644 --- a/js/actions/ReportActions.js +++ b/js/actions/ReportActions.js @@ -6,9 +6,10 @@ var models = require('../models.js'); var Report = models.Report; var Error = models.Error; -function fetchReport() { +function fetchReport(reportName) { return { - type: ReportConstants.FETCH_REPORT + type: ReportConstants.FETCH_REPORT, + reportName: reportName } } @@ -19,9 +20,25 @@ function reportFetched(report) { } } +function selectReport(report, seriesTraversal) { + return { + type: ReportConstants.SELECT_REPORT, + report: report, + seriesTraversal: seriesTraversal + } +} + +function reportSelected(flattenedReport, seriesTraversal) { + return { + type: ReportConstants.REPORT_SELECTED, + report: flattenedReport, + seriesTraversal: seriesTraversal + } +} + function fetch(report) { return function (dispatch) { - dispatch(fetchReport()); + dispatch(fetchReport(report)); $.ajax({ type: "GET", @@ -45,6 +62,48 @@ function fetch(report) { }; } +function select(report, seriesTraversal) { + return function (dispatch) { + if (!seriesTraversal) + seriesTraversal = []; + dispatch(selectReport(report, seriesTraversal)); + + // Descend the tree to the right series to flatten + var series = report; + for (var i=0; i < seriesTraversal.length; i++) { + if (!series.Series.hasOwnProperty(seriesTraversal[i])) { + dispatch(ErrorActions.clientError("Invalid series")); + return; + } + series = series.Series[seriesTraversal[i]]; + } + + // Actually flatten the data + var flattenedSeries = series.mapReduceChildren(null, + function(accumulator, currentValue, currentIndex, array) { + return accumulator + currentValue; + } + ); + + // Add back in any values from the current level + if (series.hasOwnProperty('Values')) + flattenedSeries[report.topLevelAccountName] = series.Values; + + var flattenedReport = new Report(); + + flattenedReport.ReportId = report.ReportId; + flattenedReport.Title = report.Title; + flattenedReport.Subtitle = report.Subtitle; + flattenedReport.XAxisLabel = report.XAxisLabel; + flattenedReport.YAxisLabel = report.YAxisLabel; + flattenedReport.Labels = report.Labels.slice(); + flattenedReport.FlattenedSeries = flattenedSeries; + + dispatch(reportSelected(flattenedReport, seriesTraversal)); + }; +} + module.exports = { - fetch: fetch + fetch: fetch, + select: select }; diff --git a/js/components/ReportsTab.js b/js/components/ReportsTab.js index a96c143..4a99f72 100644 --- a/js/components/ReportsTab.js +++ b/js/components/ReportsTab.js @@ -1,5 +1,10 @@ var React = require('react'); +var ReactBootstrap = require('react-bootstrap'); + +var Button = ReactBootstrap.Button; +var Panel = ReactBootstrap.Panel; + var StackedBarChart = require('../components/StackedBarChart'); module.exports = React.createClass({ @@ -10,10 +15,69 @@ module.exports = React.createClass({ componentWillMount: function() { this.props.onFetchReport("monthly_expenses"); }, + componentWillReceiveProps: function(nextProps) { + if (nextProps.reports['monthly_expenses'] && !nextProps.selectedReport.report) { + this.props.onSelectReport(nextProps.reports['monthly_expenses'], []); + } + }, + onSelectSeries: function(series) { + if (series == this.props.selectedReport.report.topLevelAccountName) + return; + var seriesTraversal = this.props.selectedReport.seriesTraversal.slice(); + seriesTraversal.push(series); + this.props.onSelectReport(this.props.reports[this.props.selectedReport.report.ReportId], seriesTraversal); + }, render: function() { - report = []; - if (this.props.reports['monthly_expenses']) - report = (); + var report = []; + if (this.props.selectedReport.report) { + var titleTracks = []; + var seriesTraversal = []; + + for (var i = 0; i < this.props.selectedReport.seriesTraversal.length; i++) { + var name = this.props.selectedReport.report.Title; + if (i > 0) + name = this.props.selectedReport.seriesTraversal[i-1]; + + // Make a closure for going up the food chain + var self = this; + var navOnClick = function() { + var onSelectReport = self.props.onSelectReport; + var report = self.props.reports[self.props.selectedReport.report.ReportId]; + var mySeriesTraversal = seriesTraversal.slice(); + return function() { + onSelectReport(report, mySeriesTraversal); + }; + }(); + titleTracks.push(( + + )); + titleTracks.push((/)); + seriesTraversal.push(this.props.selectedReport.seriesTraversal[i]); + } + if (titleTracks.length == 0) + titleTracks.push(( + + )); + else + titleTracks.push(( + + )); + + report = ( + + + ); + } return (
{report} diff --git a/js/components/StackedBarChart.js b/js/components/StackedBarChart.js index 56b3783..ee3764c 100644 --- a/js/components/StackedBarChart.js +++ b/js/components/StackedBarChart.js @@ -1,15 +1,13 @@ var d3 = require('d3'); var React = require('react'); -var Panel = require('react-bootstrap').Panel; - module.exports = React.createClass({ displayName: "StackedBarChart", - calcMinMax: function(data) { + calcMinMax: function(series) { var children = []; - for (var child in data) { - if (data.hasOwnProperty(child)) - children.push(data[child]); + for (var child in series) { + if (series.hasOwnProperty(child)) + children.push(series[child]); } var positiveValues = [0]; @@ -40,12 +38,6 @@ module.exports = React.createClass({ return Math.ceil(rangePerTick/roundTo)*roundTo; }, render: function() { - var data = this.props.data.mapReduceChildren(null, - function(accumulator, currentValue, currentIndex, array) { - return accumulator + currentValue; - } - ); - height = 400; width = 600; legendWidth = 100; @@ -54,7 +46,7 @@ module.exports = React.createClass({ height -= yMargin*2; width -= xMargin*2; - var minMax = this.calcMinMax(data); + var minMax = this.calcMinMax(this.props.report.FlattenedSeries); var y = d3.scaleLinear() .range([0, height]) .domain(minMax); @@ -63,7 +55,7 @@ module.exports = React.createClass({ var x = d3.scaleLinear() .range([0, width]) - .domain([0, this.props.data.Labels.length + 0.5]); + .domain([0, this.props.report.Labels.length + 0.5]); var bars = []; var labels = []; @@ -76,13 +68,13 @@ module.exports = React.createClass({ // negativeSum arrays var positiveSum = []; var negativeSum = []; - for (var i=0; i < this.props.data.Labels.length; i++) { + for (var i=0; i < this.props.report.Labels.length; i++) { positiveSum.push(0); negativeSum.push(0); var labelX = x(i) + barStart + barWidth/2; var labelY = height + 15; labels.push(( - {this.props.data.Labels[i]} + {this.props.report.Labels[i]} )); labels.push(( @@ -103,14 +95,24 @@ module.exports = React.createClass({ for (var i=0-xAxisMarksEvery; i > minMax[0]; i -= xAxisMarksEvery) makeXLabel(i); - //TODO handle Values from current series? var legendMap = {}; - for (var child in data) { - childId++; - var rectClasses = "chart-element chart-color" + (childId % 12); - if (data.hasOwnProperty(child)) { - for (var i=0; i < data[child].length; i++) { - var value = data[child][i]; + for (var child in this.props.report.FlattenedSeries) { + if (this.props.report.FlattenedSeries.hasOwnProperty(child)) { + childId++; + var childData = this.props.report.FlattenedSeries[child]; + var rectClasses = "chart-element chart-color" + (childId % 12); + var self = this; + var rectOnClick = function() { + var childName = child; + var onSelectSeries = self.props.onSelectSeries; + return function() { + onSelectSeries(childName); + }; + }(); + + var seriesBars = []; + for (var i=0; i < childData.length; i++) { + var value = childData[i]; if (value == 0) continue; legendMap[child] = childId; @@ -124,10 +126,15 @@ module.exports = React.createClass({ negativeSum[i] += rectHeight; } - bars.push(( - + seriesBars.push(( + )); } + bars.push(( + + {seriesBars} + + )); } } @@ -144,19 +151,17 @@ module.exports = React.createClass({ } return ( - - - - {bars} - - - {labels} - - - {legend} - - - + + + {bars} + + + {labels} + + + {legend} + + ); } }); diff --git a/js/constants/ReportConstants.js b/js/constants/ReportConstants.js index 7e8a913..da955c3 100644 --- a/js/constants/ReportConstants.js +++ b/js/constants/ReportConstants.js @@ -2,5 +2,7 @@ var keyMirror = require('keymirror'); module.exports = keyMirror({ FETCH_REPORT: null, - REPORT_FETCHED: null + REPORT_FETCHED: null, + SELECT_REPORT: null, + REPORT_SELECTED: null }); diff --git a/js/containers/ReportsTabContainer.js b/js/containers/ReportsTabContainer.js index 9a917e2..d9bb3e6 100644 --- a/js/containers/ReportsTabContainer.js +++ b/js/containers/ReportsTabContainer.js @@ -5,13 +5,15 @@ var ReportsTab = require('../components/ReportsTab'); function mapStateToProps(state) { return { - reports: state.reports + reports: state.reports, + selectedReport: state.selectedReport } } function mapDispatchToProps(dispatch) { return { - onFetchReport: function(reportname) {dispatch(ReportActions.fetch(reportname))} + onFetchReport: function(reportname) {dispatch(ReportActions.fetch(reportname))}, + onSelectReport: function(report, seriesTraversal) {dispatch(ReportActions.select(report, seriesTraversal))} } } diff --git a/js/models.js b/js/models.js index bf505bc..3efda47 100644 --- a/js/models.js +++ b/js/models.js @@ -399,16 +399,16 @@ Error.prototype.isError = function() { function Series() { this.Values = []; - this.Children = {}; + this.Series = {}; } Series.prototype.toJSONobj = function() { var json_obj = {}; json_obj.Values = this.Values; - json_obj.Children = {}; - for (var child in this.Children) { - if (this.Children.hasOwnProperty(child)) - json_obj.Children[child] = this.Children[child].toJSONobj(); + json_obj.Series = {}; + for (var child in this.Series) { + if (this.Series.hasOwnProperty(child)) + json_obj.Series[child] = this.Series[child].toJSONobj(); } return json_obj; } @@ -416,20 +416,20 @@ Series.prototype.toJSONobj = function() { Series.prototype.fromJSONobj = function(json_obj) { if (json_obj.hasOwnProperty("Values")) this.Values = json_obj.Values; - if (json_obj.hasOwnProperty("Children")) { - for (var child in json_obj.Children) { - if (json_obj.Children.hasOwnProperty(child)) - this.Children[child] = new Series(); - this.Children[child].fromJSONobj(json_obj.Children[child]); + if (json_obj.hasOwnProperty("Series")) { + for (var child in json_obj.Series) { + if (json_obj.Series.hasOwnProperty(child)) + this.Series[child] = new Series(); + this.Series[child].fromJSONobj(json_obj.Series[child]); } } } Series.prototype.mapReduceChildren = function(mapFn, reduceFn) { var children = {} - for (var child in this.Children) { - if (this.Children.hasOwnProperty(child)) - children[child] = this.Children[child].mapReduce(mapFn, reduceFn); + for (var child in this.Series) { + if (this.Series.hasOwnProperty(child)) + children[child] = this.Series[child].mapReduce(mapFn, reduceFn); } return children; } @@ -441,9 +441,9 @@ Series.prototype.mapReduce = function(mapFn, reduceFn) { else childValues.push(this.Values.slice()); - for (var child in this.Children) { - if (this.Children.hasOwnProperty(child)) - childValues.push(this.Children[child].mapReduce(mapFn, reduceFn)); + for (var child in this.Series) { + if (this.Series.hasOwnProperty(child)) + childValues.push(this.Series[child].mapReduce(mapFn, reduceFn)); } var reducedValues = []; @@ -466,8 +466,11 @@ function Report() { this.YAxisLabel = ""; this.Labels = []; this.Series = {}; + this.FlattenedSeries = {}; } +Report.prototype.topLevelAccountName = "(top level)"; + Report.prototype.toJSON = function() { var json_obj = {}; json_obj.ReportId = this.ReportId; diff --git a/js/reducers/MoneyGoReducer.js b/js/reducers/MoneyGoReducer.js index bd534bc..8546436 100644 --- a/js/reducers/MoneyGoReducer.js +++ b/js/reducers/MoneyGoReducer.js @@ -8,6 +8,7 @@ var SecurityTemplateReducer = require('./SecurityTemplateReducer'); var SelectedAccountReducer = require('./SelectedAccountReducer'); var SelectedSecurityReducer = require('./SelectedSecurityReducer'); var ReportReducer = require('./ReportReducer'); +var SelectedReportReducer = require('./SelectedReportReducer'); var ErrorReducer = require('./ErrorReducer'); module.exports = Redux.combineReducers({ @@ -19,5 +20,6 @@ module.exports = Redux.combineReducers({ selectedAccount: SelectedAccountReducer, selectedSecurity: SelectedSecurityReducer, reports: ReportReducer, + selectedReport: SelectedReportReducer, error: ErrorReducer }); diff --git a/js/reducers/SelectedReportReducer.js b/js/reducers/SelectedReportReducer.js new file mode 100644 index 0000000..87ae68c --- /dev/null +++ b/js/reducers/SelectedReportReducer.js @@ -0,0 +1,23 @@ +var assign = require('object-assign'); + +var ReportConstants = require('../constants/ReportConstants'); +var UserConstants = require('../constants/UserConstants'); + +const initialState = { + report: null, + seriesTraversal: [] +}; + +module.exports = function(state = initialState, action) { + switch (action.type) { + case ReportConstants.REPORT_SELECTED: + return { + report: action.report, + seriesTraversal: action.seriesTraversal + }; + case UserConstants.USER_LOGGEDOUT: + return initialState; + default: + return state; + } +}; diff --git a/reports.go b/reports.go index d4067d3..7c15826 100644 --- a/reports.go +++ b/reports.go @@ -25,8 +25,8 @@ const ( const luaTimeoutSeconds time.Duration = 5 // maximum time a lua request can run for type Series struct { - Values []float64 - Children map[string]*Series + Values []float64 + Series map[string]*Series } type Report struct { diff --git a/reports_lua.go b/reports_lua.go index efb0e0f..9da36fe 100644 --- a/reports_lua.go +++ b/reports_lua.go @@ -97,8 +97,8 @@ func luaReportSeries(L *lua.LState) int { ud.Value = s } else { report.Series[name] = &Series{ - Children: make(map[string]*Series), - Values: make([]float64, cap(report.Labels)), + Series: make(map[string]*Series), + Values: make([]float64, cap(report.Labels)), } ud.Value = report.Series[name] } @@ -157,8 +157,8 @@ func luaSeries__index(L *lua.LState) int { switch field { case "Value", "value": L.Push(L.NewFunction(luaSeriesValue)) - case "Series", "series", "Child", "child": - L.Push(L.NewFunction(luaSeriesChildren)) + case "Series", "series": + L.Push(L.NewFunction(luaSeriesSeries)) default: L.ArgError(2, "unexpected series attribute: "+field) } @@ -179,20 +179,20 @@ func luaSeriesValue(L *lua.LState) int { return 0 } -func luaSeriesChildren(L *lua.LState) int { +func luaSeriesSeries(L *lua.LState) int { parent := luaCheckSeries(L, 1) name := L.CheckString(2) ud := L.NewUserData() - s, ok := parent.Children[name] + s, ok := parent.Series[name] if ok { ud.Value = s } else { - parent.Children[name] = &Series{ - Children: make(map[string]*Series), - Values: make([]float64, cap(parent.Values)), + parent.Series[name] = &Series{ + Series: make(map[string]*Series), + Values: make([]float64, cap(parent.Values)), } - ud.Value = parent.Children[name] + ud.Value = parent.Series[name] } L.SetMetatable(ud, L.GetTypeMetatable(luaSeriesTypeName)) L.Push(ud) diff --git a/securities_lua.go b/securities_lua.go index 77feb5b..6a9d347 100644 --- a/securities_lua.go +++ b/securities_lua.go @@ -53,7 +53,6 @@ func luaGetSecurities(L *lua.LState) int { return 1 } -// Registers my security type to given L. func luaRegisterSecurities(L *lua.LState) { mt := L.NewTypeMetatable(luaSecurityTypeName) L.SetGlobal("security", mt) diff --git a/static/css/reports.css b/static/css/reports.css index f522620..45bba8a 100644 --- a/static/css/reports.css +++ b/static/css/reports.css @@ -28,7 +28,7 @@ } .chart-color8 { fill: #ff7f00; - fill: #df5f00; + stroke: #df5f00; } .chart-color9 { fill: #cab2d6; @@ -50,7 +50,7 @@ .chart-element { stroke-width: 0; } -.chart-element:hover { +g.chart-series:hover .chart-element { stroke-width: 2; } .chart-legend rect {