From ce6660b5751957c313589074d51d0dda2ab5aef8 Mon Sep 17 00:00:00 2001 From: Aaron Lindsay Date: Wed, 26 Oct 2016 06:58:14 -0400 Subject: [PATCH] Add initial UI for user-editable securities --- js/actions/SecurityActions.js | 130 +++++++- js/actions/SecurityTemplateActions.js | 62 ++++ js/components/AccountsTab.js | 1 - js/components/MoneyGoApp.js | 11 +- js/components/SecuritiesTab.js | 363 ++++++++++++++++++++++ js/constants/SecurityConstants.js | 3 +- js/constants/SecurityTemplateConstants.js | 6 + js/containers/SecuritiesTabContainer.js | 35 +++ js/models.js | 4 + js/reducers/MoneyGoReducer.js | 4 + js/reducers/SecurityTemplateReducer.js | 33 ++ js/reducers/SelectedSecurityReducer.js | 23 ++ securities.go | 20 +- static/stylesheet.css | 4 +- 14 files changed, 689 insertions(+), 10 deletions(-) create mode 100644 js/actions/SecurityTemplateActions.js create mode 100644 js/components/SecuritiesTab.js create mode 100644 js/constants/SecurityTemplateConstants.js create mode 100644 js/containers/SecuritiesTabContainer.js create mode 100644 js/reducers/SecurityTemplateReducer.js create mode 100644 js/reducers/SelectedSecurityReducer.js diff --git a/js/actions/SecurityActions.js b/js/actions/SecurityActions.js index 268dac4..1d92aa4 100644 --- a/js/actions/SecurityActions.js +++ b/js/actions/SecurityActions.js @@ -19,6 +19,52 @@ function securitiesFetched(securities) { } } +function createSecurity() { + return { + type: SecurityConstants.CREATE_SECURITY + } +} + +function securityCreated(security) { + return { + type: SecurityConstants.SECURITY_CREATED, + security: security + } +} + +function updateSecurity() { + return { + type: SecurityConstants.UPDATE_SECURITY + } +} + +function securityUpdated(security) { + return { + type: SecurityConstants.SECURITY_UPDATED, + security: security + } +} + +function removeSecurity() { + return { + type: SecurityConstants.REMOVE_SECURITY + } +} + +function securityRemoved(securityId) { + return { + type: SecurityConstants.SECURITY_REMOVED, + securityId: securityId + } +} + +function securitySelected(securityId) { + return { + type: SecurityConstants.SECURITY_SELECTED, + securityId: securityId + } +} + function fetchAll() { return function (dispatch) { dispatch(fetchSecurities()); @@ -47,6 +93,88 @@ function fetchAll() { }; } +function create(security) { + return function (dispatch) { + dispatch(createSecurity()); + + $.ajax({ + type: "POST", + dataType: "json", + url: "security/", + data: {security: security.toJSON()}, + success: function(data, status, jqXHR) { + var e = new Error(); + e.fromJSON(data); + if (e.isError()) { + ErrorActions.serverError(e); + } else { + var s = new Security(); + s.fromJSON(data); + dispatch(securityCreated(s)); + } + }, + error: function(jqXHR, status, error) { + ErrorActions.ajaxError(e); + } + }); + }; +} + +function update(security) { + return function (dispatch) { + dispatch(updateSecurity()); + + $.ajax({ + type: "PUT", + dataType: "json", + url: "security/"+security.SecurityId+"/", + data: {security: security.toJSON()}, + success: function(data, status, jqXHR) { + var e = new Error(); + e.fromJSON(data); + if (e.isError()) { + ErrorActions.serverError(e); + } else { + var s = new Security(); + s.fromJSON(data); + dispatch(securityUpdated(s)); + } + }, + error: function(jqXHR, status, error) { + ErrorActions.ajaxError(e); + } + }); + }; +} + +function remove(security) { + return function(dispatch) { + dispatch(removeSecurity()); + + $.ajax({ + type: "DELETE", + dataType: "json", + url: "security/"+security.SecurityId+"/", + success: function(data, status, jqXHR) { + var e = new Error(); + e.fromJSON(data); + if (e.isError()) { + ErrorActions.serverError(e); + } else { + dispatch(securityRemoved(security.SecurityId)); + } + }, + error: function(jqXHR, status, error) { + ErrorActions.ajaxError(e); + } + }); + }; +} + module.exports = { - fetchAll: fetchAll + fetchAll: fetchAll, + create: create, + update: update, + remove: remove, + select: securitySelected }; diff --git a/js/actions/SecurityTemplateActions.js b/js/actions/SecurityTemplateActions.js new file mode 100644 index 0000000..7d30db2 --- /dev/null +++ b/js/actions/SecurityTemplateActions.js @@ -0,0 +1,62 @@ +var SecurityTemplateConstants = require('../constants/SecurityTemplateConstants'); + +var ErrorActions = require('./ErrorActions'); + +var models = require('../models.js'); +var Security = models.Security; +var Error = models.Error; + +function searchSecurityTemplates(searchString, searchType) { + return { + type: SecurityTemplateConstants.SEARCH_SECURITY_TEMPLATES, + searchString: searchString, + searchType: searchType + } +} + +function securityTemplatesSearched(searchString, searchType, securities) { + return { + type: SecurityTemplateConstants.SECURITY_TEMPLATES_SEARCHED, + searchString: searchString, + searchType: searchType, + securities: securities + } +} + +function search(searchString, searchType, limit) { + return function (dispatch) { + dispatch(searchSecurityTemplates(searchString, searchType)); + + if (searchString == "") + return; + + $.ajax({ + type: "GET", + dataType: "json", + url: "securitytemplate/?search="+searchString+"&type="+searchType+"&limit="+limit, + success: function(data, status, jqXHR) { + var e = new Error(); + e.fromJSON(data); + if (e.isError()) { + ErrorActions.serverError(e); + } else if (data.securities == null) { + dispatch(securityTemplatesSearched(searchString, searchType, new Array())); + } else { + dispatch(securityTemplatesSearched(searchString, searchType, + data.securities.map(function(json) { + var s = new Security(); + s.fromJSON(json); + return s; + }))); + } + }, + error: function(jqXHR, status, error) { + ErrorActions.ajaxError(e); + } + }); + }; +} + +module.exports = { + search: search +}; diff --git a/js/components/AccountsTab.js b/js/components/AccountsTab.js index b585ef5..de2866d 100644 --- a/js/components/AccountsTab.js +++ b/js/components/AccountsTab.js @@ -15,7 +15,6 @@ var ButtonGroup = ReactBootstrap.ButtonGroup; var Glyphicon = ReactBootstrap.Glyphicon; var ListGroup = ReactBootstrap.ListGroup; var ListGroupItem = ReactBootstrap.ListGroupItem; -var Collapse = ReactBootstrap.Collapse; var Alert = ReactBootstrap.Alert; var Modal = ReactBootstrap.Modal; var Collapse = ReactBootstrap.Collapse; diff --git a/js/components/MoneyGoApp.js b/js/components/MoneyGoApp.js index 4f21f04..98d27de 100644 --- a/js/components/MoneyGoApp.js +++ b/js/components/MoneyGoApp.js @@ -10,6 +10,7 @@ var TopBarContainer = require('../containers/TopBarContainer'); var NewUserForm = require('./NewUserForm'); var AccountSettingsModalContainer = require('../containers/AccountSettingsModalContainer'); var AccountsTabContainer = require('../containers/AccountsTabContainer'); +var SecuritiesTabContainer = require('../containers/SecuritiesTabContainer'); module.exports = React.createClass({ displayName: "MoneyGoApp", @@ -67,9 +68,13 @@ module.exports = React.createClass({ - Scheduled transactions go here... - Budgets go here... - Reports go here... + + + + Scheduled transactions go here... + Budgets go here... + Reports go here... ); else mainContent = ( diff --git a/js/components/SecuritiesTab.js b/js/components/SecuritiesTab.js new file mode 100644 index 0000000..f842d6c --- /dev/null +++ b/js/components/SecuritiesTab.js @@ -0,0 +1,363 @@ +var React = require('react'); +var ReactDOM = require('react-dom'); + +var ReactBootstrap = require('react-bootstrap'); +var Grid = ReactBootstrap.Grid; +var Row = ReactBootstrap.Row; +var Col = ReactBootstrap.Col; +var Form = ReactBootstrap.Form; +var FormGroup = ReactBootstrap.FormGroup; +var FormControl = ReactBootstrap.FormControl; +var ControlLabel = ReactBootstrap.ControlLabel; +var Button = ReactBootstrap.Button; +var ButtonGroup = ReactBootstrap.ButtonGroup; +var ButtonToolbar = ReactBootstrap.ButtonToolbar; +var Glyphicon = ReactBootstrap.Glyphicon; +var ListGroup = ReactBootstrap.ListGroup; +var ListGroupItem = ReactBootstrap.ListGroupItem; +var Modal = ReactBootstrap.Modal; +var Panel = ReactBootstrap.Panel; + +var Combobox = require('react-widgets').Combobox; + +var models = require('../models'); +var Security = models.Security; +var SecurityType = models.SecurityType; +var SecurityTypeList = models.SecurityTypeList; + +const SecurityTemplatePanel = React.createClass({ + handleSearchChange: function(){ + this.props.onSearchTemplates(ReactDOM.findDOMNode(this.refs.search).value, 0, this.props.maxResults + 1); + }, + renderTemplateList: function() { + var templates = this.props.securityTemplates; + if (this.props.search != "") { + var items = []; + for (var i = 0; i < templates.length && i < 15; i++) { + var template = templates[i]; + var self = this; + var onClickFn = (function() { + var j = i; + return function(){self.props.onSelectTemplate(templates[j])}; + })(); + var key = template.Type.toString() + template.AlternateId; + items.push(( + + {template.Name} - {template.Description} + + )); + } + if (templates.length > this.props.maxResults) { + items.push(( + + Too many templates to display, please refine your search... + + )); + } else if (templates.length == 0) { + items.push(( + + Sorry, no templates matched your search... + + )); + } + return ( +
+
+ Select a template to populate your security: + + {items} + +
+ ); + } + }, + render: function() { + return ( + + + {this.renderTemplateList()} + + ); + } +}); + +const AddEditSecurityModal = React.createClass({ + getInitialState: function() { + var s = { + securityid: -1, + name: "", + description: "", + symbol: "", + precision: 0, + type: 1, + alternateid: "" + }; + if (this.props.editSecurity != null) { + s.securityid = this.props.editSecurity.SecurityId; + s.name = this.props.editSecurity.Name; + s.description = this.props.editSecurity.Description; + s.symbol = this.props.editSecurity.Symbol; + s.precision = this.props.editSecurity.Precision; + s.type = this.props.editSecurity.Type; + s.alternateid = this.props.editSecurity.AlternateId; + } + return s; + }, + onSelectTemplate: function(template) { + this.setState({ + name: template.Name, + description: template.Description, + symbol: template.Symbol, + precision: template.Precision, + type: template.Type, + alternateid: template.AlternateId + }); + }, + handleCancel: function() { + if (this.props.onCancel != null) + this.props.onCancel(); + }, + handleNameChange: function() { + this.setState({ + name: ReactDOM.findDOMNode(this.refs.name).value, + }); + }, + handleDescriptionChange: function() { + this.setState({ + description: ReactDOM.findDOMNode(this.refs.description).value, + }); + }, + handleSymbolChange: function() { + this.setState({ + symbol: ReactDOM.findDOMNode(this.refs.symbol).value, + }); + }, + handlePrecisionChange: function() { + this.setState({ + precision: +ReactDOM.findDOMNode(this.refs.precision).value, + }); + }, + handleTypeChange: function(type) { + if (type.hasOwnProperty('TypeId')) + this.setState({ + type: type.TypeId + }); + }, + handleAlternateIdChange: function() { + this.setState({ + alternateid: ReactDOM.findDOMNode(this.refs.alternateid).value, + }); + }, + handleSubmit: function() { + var s = new Security(); + + if (this.props.editSecurity != null) + s.SecurityId = this.state.securityid; + s.Name = this.state.name; + s.Description = this.state.description; + s.Symbol = this.state.symbol; + s.Precision = this.state.precision; + s.Type = this.state.type; + s.AlternateId = this.state.alternateid; + + if (this.props.onSubmit != null) + this.props.onSubmit(s); + }, + componentWillReceiveProps: function(nextProps) { + if (nextProps.show && !this.props.show) { + this.setState(this.getInitialState()); + } + }, + render: function() { + var headerText = (this.props.editSecurity != null) ? "Edit" : "Create New"; + var buttonText = (this.props.editSecurity != null) ? "Save Changes" : "Create Security"; + var alternateidname = (this.state.type == SecurityType.Currency) ? "ISO 4217 Code" : "CUSIP"; + return ( + + + {headerText} Security + + + +
+ + Name + + + + + + Description + + + + + + Symbol or Ticker + + + + + + Smallest Fraction Traded + + + + + + + + + + + + + Security Type + + + + + + {alternateidname} + + + + +
+
+ + + + + + +
+ ); + } +}); + +const SecurityList = React.createClass({ + render: function() { + var children = []; + var self = this; + for (var securityId in this.props.securities) { + if (this.props.securities.hasOwnProperty(securityId)) { + var buttonStyle = (securityId == this.props.selectedSecurity) ? "info" : "link"; + var onClickFn = (function() { + var id = securityId; + return function(){self.props.onSelectSecurity(id)}; + })(); + children.push(()); + } + } + + return ( +
+ {children} +
+ ); + } +}); + +module.exports = React.createClass({ + displayName: "SecuritiesTab", + getInitialState: function() { + return { + creatingNewSecurity: false, + editingSecurity: false + }; + }, + handleNewSecurity: function() { + this.setState({creatingNewSecurity: true}); + }, + handleEditSecurity: function() { + this.setState({editingSecurity: true}); + }, + handleCreationCancel: function() { + this.setState({creatingNewSecurity: false}); + }, + handleCreationSubmit: function(security) { + this.setState({creatingNewSecurity: false}); + this.props.onCreateSecurity(security); + }, + handleEditingCancel: function() { + this.setState({editingSecurity: false}); + }, + handleEditingSubmit: function(security) { + this.setState({editingSecurity: false}); + this.props.onUpdateSecurity(security); + }, + render: function() { + var editDisabled = this.props.selectedSecurity == -1; + + var selectedSecurity = null; + if (this.props.securities.hasOwnProperty(this.props.selectedSecurity)) + selectedSecurity = this.props.securities[this.props.selectedSecurity]; + + return ( + + + + + + + + + + + + + ); + } +}); diff --git a/js/constants/SecurityConstants.js b/js/constants/SecurityConstants.js index 5c38b5b..a013df9 100644 --- a/js/constants/SecurityConstants.js +++ b/js/constants/SecurityConstants.js @@ -8,5 +8,6 @@ module.exports = keyMirror({ UPDATE_SECURITY: null, SECURITY_UPDATED: null, REMOVE_SECURITY: null, - SECURITY_REMOVED: null + SECURITY_REMOVED: null, + SECURITY_SELECTED: null }); diff --git a/js/constants/SecurityTemplateConstants.js b/js/constants/SecurityTemplateConstants.js new file mode 100644 index 0000000..b8fc901 --- /dev/null +++ b/js/constants/SecurityTemplateConstants.js @@ -0,0 +1,6 @@ +var keyMirror = require('keymirror'); + +module.exports = keyMirror({ + SEARCH_SECURITY_TEMPLATES: null, + SECURITY_TEMPLATES_SEARCHED: null +}); diff --git a/js/containers/SecuritiesTabContainer.js b/js/containers/SecuritiesTabContainer.js new file mode 100644 index 0000000..7ab6eb7 --- /dev/null +++ b/js/containers/SecuritiesTabContainer.js @@ -0,0 +1,35 @@ +var connect = require('react-redux').connect; + +var SecurityActions = require('../actions/SecurityActions'); +var SecurityTemplateActions = require('../actions/SecurityTemplateActions'); +var SecuritiesTab = require('../components/SecuritiesTab'); + +function mapStateToProps(state) { + var selectedSecurityAccounts = []; + for (var accountId in state.accounts) { + if (state.accounts.hasOwnProperty(accountId) + && state.accounts[accountId].SecurityId == state.selectedSecurity) + selectedSecurityAccounts.push(state.accounts[accountId]); + } + return { + securities: state.securities, + selectedSecurityAccounts: selectedSecurityAccounts, + selectedSecurity: state.selectedSecurity, + securityTemplates: state.securityTemplates + } +} + +function mapDispatchToProps(dispatch) { + return { + onCreateSecurity: function(security) {dispatch(SecurityActions.create(security))}, + onUpdateSecurity: function(security) {dispatch(SecurityActions.update(security))}, + onDeleteSecurity: function(securityId) {dispatch(SecurityActions.remove(securityId))}, + onSelectSecurity: function(securityId) {dispatch(SecurityActions.select(securityId))}, + onSearchTemplates: function(search, type, limit) {dispatch(SecurityTemplateActions.search(search, type, limit))} + } +} + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(SecuritiesTab) diff --git a/js/models.js b/js/models.js index 51c8a8a..404ae07 100644 --- a/js/models.js +++ b/js/models.js @@ -94,6 +94,7 @@ function Security() { this.Symbol = ""; this.Precision = -1; this.Type = -1; + this.AlternateId = ""; } Security.prototype.toJSON = function() { @@ -104,6 +105,7 @@ Security.prototype.toJSON = function() { json_obj.Symbol = this.Symbol; json_obj.Precision = this.Precision; json_obj.Type = this.Type; + json_obj.AlternateId = this.AlternateId; return JSON.stringify(json_obj); } @@ -122,6 +124,8 @@ Security.prototype.fromJSON = function(json_input) { this.Precision = json_obj.Precision; if (json_obj.hasOwnProperty("Type")) this.Type = json_obj.Type; + if (json_obj.hasOwnProperty("AlternateId")) + this.AlternateId = json_obj.AlternateId; } Security.prototype.isSecurity = function() { diff --git a/js/reducers/MoneyGoReducer.js b/js/reducers/MoneyGoReducer.js index 0c4e12e..4926973 100644 --- a/js/reducers/MoneyGoReducer.js +++ b/js/reducers/MoneyGoReducer.js @@ -4,7 +4,9 @@ var UserReducer = require('./UserReducer'); var SessionReducer = require('./SessionReducer'); var AccountReducer = require('./AccountReducer'); var SecurityReducer = require('./SecurityReducer'); +var SecurityTemplateReducer = require('./SecurityTemplateReducer'); var SelectedAccountReducer = require('./SelectedAccountReducer'); +var SelectedSecurityReducer = require('./SelectedSecurityReducer'); var ErrorReducer = require('./ErrorReducer'); module.exports = Redux.combineReducers({ @@ -12,6 +14,8 @@ module.exports = Redux.combineReducers({ session: SessionReducer, accounts: AccountReducer, securities: SecurityReducer, + securityTemplates: SecurityTemplateReducer, selectedAccount: SelectedAccountReducer, + selectedSecurity: SelectedSecurityReducer, error: ErrorReducer }); diff --git a/js/reducers/SecurityTemplateReducer.js b/js/reducers/SecurityTemplateReducer.js new file mode 100644 index 0000000..b1ade5a --- /dev/null +++ b/js/reducers/SecurityTemplateReducer.js @@ -0,0 +1,33 @@ +var assign = require('object-assign'); + +var SecurityTemplateConstants = require('../constants/SecurityTemplateConstants'); +var UserConstants = require('../constants/UserConstants'); + +var SecurityType = require('../models').SecurityType; + +module.exports = function(state = {search: "", type: 0, templates: [], searchNumber: 0}, action) { + switch (action.type) { + case SecurityTemplateConstants.SEARCH_SECURITY_TEMPLATES: + return { + search: action.searchString, + type: action.searchType, + templates: [] + }; + case SecurityTemplateConstants.SECURITY_TEMPLATES_SEARCHED: + if ((action.searchString != state.search) || (action.searchType != state.type)) + return state; + return { + search: action.searchString, + type: action.searchType, + templates: action.securities + }; + case UserConstants.USER_LOGGEDOUT: + return { + search: "", + type: 0, + templates: [] + }; + default: + return state; + } +}; diff --git a/js/reducers/SelectedSecurityReducer.js b/js/reducers/SelectedSecurityReducer.js new file mode 100644 index 0000000..b792b80 --- /dev/null +++ b/js/reducers/SelectedSecurityReducer.js @@ -0,0 +1,23 @@ +var SecurityConstants = require('../constants/SecurityConstants'); +var UserConstants = require('../constants/UserConstants'); + +module.exports = function(state = -1, action) { + switch (action.type) { + case SecurityConstants.SECURITIES_FETCHED: + for (var i = 0; i < action.securities.length; i++) { + if (action.securities[i].SecurityId == state) + return state; + } + return -1; + case SecurityConstants.SECURITY_REMOVED: + if (action.securityId == state) + return -1; + return state; + case SecurityConstants.SECURITY_SELECTED: + return action.securityId; + case UserConstants.USER_LOGGEDOUT: + return -1; + default: + return state; + } +}; diff --git a/securities.go b/securities.go index 03793f4..a56c94c 100644 --- a/securities.go +++ b/securities.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "net/url" + "strconv" "strings" ) @@ -58,7 +59,7 @@ func (sl *SecurityList) Write(w http.ResponseWriter) error { return enc.Encode(sl) } -func SearchSecurityTemplates(search string, _type int64) []*Security { +func SearchSecurityTemplates(search string, _type int64, limit int64) []*Security { upperSearch := strings.ToUpper(search) var results []*Security for i, security := range SecurityTemplates { @@ -67,6 +68,9 @@ func SearchSecurityTemplates(search string, _type int64) []*Security { strings.Contains(strings.ToUpper(security.Symbol), upperSearch) { if _type == 0 || _type == security.Type { results = append(results, &SecurityTemplates[i]) + if limit != -1 && int64(len(results)) >= limit { + break + } } } } @@ -298,10 +302,22 @@ func SecurityTemplateHandler(w http.ResponseWriter, r *http.Request) { var sl SecurityList query, _ := url.ParseQuery(r.URL.RawQuery) + + var limit int64 = -1 search := query.Get("search") _type := GetSecurityType(query.Get("type")) - securities := SearchSecurityTemplates(search, _type) + limitstring := query.Get("limit") + if limitstring != "" { + limitint, err := strconv.ParseInt(limitstring, 10, 0) + if err != nil { + WriteError(w, 3 /*Invalid Request*/) + return + } + limit = limitint + } + + securities := SearchSecurityTemplates(search, _type, limit) sl.Securities = &securities err := (&sl).Write(w) diff --git a/static/stylesheet.css b/static/stylesheet.css index e6a8e77..cdc733a 100644 --- a/static/stylesheet.css +++ b/static/stylesheet.css @@ -61,7 +61,7 @@ div.accounttree-root div { height: 100%-100px; overflow: auto; } -.account-column { +.account-column, .securitylist-column { padding: 15px 15px 43px 15px; border-right: 1px solid #DDD; border-left: 1px solid #DDD; @@ -82,7 +82,7 @@ div.accounttree-root div { height: 100%; overflow: auto; } -.transactions-column { +.transactions-column, .securities-column { padding: 15px; border-right: 1px solid #DDD; }