Permalink
Browse files

"recall" prior events for easy entry of similar events

This is the "memorized" transactions feature. You enter an actor
name, click the "recall" link, and Bucketwise pulls up the last
event you entered that used that actor. Click "recall" again and
it gets the one before that, etc.

Note that, for usability purposes, this swaps the position of
the actor and amount fields. Before, you'd enter the amount and
then the actor. Now, you enter the actor and then the amount
(because you may want to recall the amount based on the actor).
  • Loading branch information...
1 parent 093e620 commit 290f3241a822c0b9b67d2847c5021f468537a961 @jamis committed May 10, 2009
View
@@ -1,5 +1,7 @@
=== (new)
+* Recall previously entered transactions for easy entry of similar events [Jamis Buck]
+
* Hide bucket list if there are less than 2 buckets for an account [Jamis Buck]
* Increase "recent buckets" window size to 10 (from 5) [Jamis Buck]
View
2 TODO
@@ -3,6 +3,7 @@ KNOWN ISSUES
--------------------------------------------------------------------------
* After adding an event with a new tag, the tag autocompletion won't pick up the new tag until the page is reloaded.
+* After adding an event with a new actor, the actor autocompletion won't pick up the new actor until the page is reloaded.
* Merging one bucket into another can result in events having two line-items referencing the same bucket.
--------------------------------------------------------------------------
@@ -18,7 +19,6 @@ FEATURES that would be nice to have someday (in no particular order)
* better 404 and 500 error pages
* searching
* reporting
-* transaction templates ("saved" or "memorized" transactions)
* scheduled transactions (occur automatically at specified intervals)
* print stylesheet
* oauth authentication for API
@@ -7,6 +7,13 @@ class EventsController < ApplicationController
def index
respond_to do |format|
+ format.js do
+ json = events.to_json(eager_options(:root => "events", :include => { :tagged_items => { :only => [:amount, :id], :methods => :name }, :line_items => { :only => [:account_id, :bucket_id, :amount, :role], :methods => [] }}))
+
+ render :update do |page|
+ page << "Events.doneLoadingRecalledEvents(#{json})"
+ end
+ end
format.xml do
render :xml => events.to_xml(eager_options(:root => "events"))
end
@@ -73,7 +80,7 @@ def destroy
protected
attr_reader :event, :container, :account, :bucket, :tag, :events
- helper_method :event
+ helper_method :event, :container, :account, :bucket
def find_event
@event = Event.find(params[:id])
@@ -114,7 +121,7 @@ def find_events
raise ArgumentError, "unsupported container type #{container.class}"
end
- more_pages, list = container.send(association).send(method, params[:page], :size => params[:size])
+ more_pages, list = container.send(association).send(method, params[:page], :size => params[:size], :actor => params[:actor])
unless list.first.is_a?(Event)
list = list.map do |item|
event = item.event
View
@@ -2,9 +2,16 @@ class Actor < ActiveRecord::Base
belongs_to :subscription
has_many :events
+ validates_presence_of :name, :sort_name
+ attr_accessible :name, :sort_name
+
+ def self.normalize_name(name)
+ name.strip.upcase
+ end
+
def self.normalize(name)
name = name.strip
- sort_name = name.upcase
+ sort_name = normalize_name(name)
actor = find_by_sort_name(sort_name)
if actor
View
@@ -130,6 +130,12 @@ def to_xml(options={})
super(options.merge(:methods => methods, :except => except))
end
+ def to_json(options={})
+ methods = Array(options[:methods]).dup
+ methods |= [:balance, :value, :role]
+ super(options.merge(:methods => methods))
+ end
+
protected
def line_item_validations
View
@@ -12,8 +12,20 @@ def recent(n=0, options={})
size = (options[:size] || DEFAULT_PAGE_SIZE).to_i
n = n.to_i
- records = find(:all, :include => :account_items,
- :order => "created_at DESC",
+ joins = []
+ conditions = []
+ parameters = []
+
+ if options[:actor]
+ joins << "LEFT JOIN actors ON actors.id = events.actor_id"
+ conditions << "actors.sort_name = ?"
+ parameters << Actor.normalize_name(options[:actor])
+ end
+
+ records = find(:all, :joins => joins,
+ :conditions => conditions.any? ? [conditions.join(" AND "), *parameters] : nil,
+ :include => :account_items,
+ :order => "events.created_at DESC",
:limit => size + 1,
:offset => n * size)
@@ -9,6 +9,8 @@ class TaggedItem < ActiveRecord::Base
attr_accessible :tag, :tag_id, :amount
+ delegate :name, :to => :tag
+
def tag_id=(value)
case value
when Fixnum, /^\s*\d+\s*$/ then super(value)
@@ -8,18 +8,21 @@
= form.calendar_date_select :occurred_on, :size => 10
%p
- %span.expense_label <strong>How much</strong> was paid?
- %span.deposit_label <strong>How much</strong> was deposited?
- %span.transfer_label <strong>How much</strong> was transferred?
- == $#{text_field_tag :amount, event_amount_value, :size => 8, :id => "expense_total", :class => "number", :onchange => "Events.updateUnassigned()"}
-
- %p
%span.expense_label <strong>Who</strong> received the payment?
%span.deposit_label <strong>Where</strong> did this deposit come from?
%span.transfer_label <strong>What</strong> was this transfer for?
= form.text_field :actor_name, :size => 30
- #event_actor_name_select.autocomplete_select{:style => "display: none"}
- = javascript_tag "Events.autocompleteActorField()"
+ - if form.object.new_record?
+ %span#recall_event{:style => "display: none"}= link_to_function "(recall)", "Events.recallEvent(#{subscription_events_path(subscription).to_json})"
+
+ #event_actor_name_select.autocomplete_select{:style => "display: none"}
+ = javascript_tag "Events.autocompleteActorField()"
+
+ %p
+ %span.expense_label <strong>How much</strong> was paid?
+ %span.deposit_label <strong>How much</strong> was deposited?
+ %span.transfer_label <strong>How much</strong> was transferred?
+ == $#{text_field_tag :amount, event_amount_value, :size => 8, :id => "expense_total", :class => "number", :onchange => "Events.updateUnassigned()"}
%p#memo_link{:style => visible?(!event_wants_memo?)}
<strong>Got more to say?</strong>
View
@@ -135,6 +135,8 @@ Returns the most recent events to have been added to BucketWise. They will be so
You can also specify the "include" query parameter as a comma-delimited list of any combination of "user", "line_items", and "tagged_items". Specifying "user" will include the user who created the event in the response. "line_items" will nest the line-items for the event in the response. "tagged_items" will nest the tagged items for the event in the response.
+Lastly, you can specify the "actor" query parameter to return only transactions where the given actor is involved.
+
=== GET /accounts/1/events.xml
Returns a single page of events associated with the given account, ordered by the date they were said to have occurred. As above, it accepts both "page" and "size" query parameters to control which page, and how many results are returned.
@@ -142,21 +142,27 @@ var Events = {
var li = document.createElement("li");
li.innerHTML = $('template.' + section).innerHTML;
ol.appendChild(li);
+ var input = li.down("input");
if(populate) {
var acctSelect = $('account_for_' + section);
var acctId = $F('account_for_' + section);
- Events.populateBucket(li.down("select"), acctId,
+ var bucketSelect = li.down("select");
+ Events.populateBucket(bucketSelect, acctId,
{'skipAside':(section=='credit_options')});
+ if(populate != true) {
+ bucketSelect.setValue(populate.bucket_id);
+ input.setValue(Money.formatValue(Math.abs(populate.amount)));
+ }
}
- li.down("input").focus();
+ input.focus();
},
removeLineItem: function(li) {
li.remove();
Events.updateUnassigned();
},
- addTaggedItem: function() {
+ addTaggedItem: function(item) {
var ol = $('tagged_items');
var li = document.createElement("li");
var content = $('template.tags').innerHTML;
@@ -166,6 +172,11 @@ var Events = {
Events.autocompleteTagField(id);
li.down("input").focus();
+
+ if(item) {
+ li.down("input.number").setValue(Money.formatValue(item.amount));
+ li.down("input.tag").setValue(item.name);
+ }
},
autocompleteTagField: function(id, options) {
@@ -195,6 +206,17 @@ var Events = {
new Autocompleter.Local('event_actor_name', "event_actor_name_select",
Events.actors, options);
+
+ var element = $('event_actor_name');
+
+ element.observe('keyup', function() {
+ Events.recalledEvents = null;
+ if(element.present()) {
+ $('recall_event').show();
+ } else {
+ $('recall_event').hide();
+ }
+ });
},
removeTaggedItem: function(li) {
@@ -536,14 +558,18 @@ var Events = {
$('tags').down("input").focus();
},
- revealPartialTags: function() {
- Events.addTaggedItem();
- Events.addTaggedItem();
+ revealPartialTags: function(bare) {
+ if(!bare) {
+ Events.addTaggedItem();
+ Events.addTaggedItem();
+ }
$('tag_items_collapsed').hide();
$('tag_items').show();
- $('tag_items').down('input').focus();
+ if(!bare) {
+ $('tag_items').down('input').focus();
+ }
},
reset: function() {
@@ -568,6 +594,8 @@ var Events = {
$('tagged_items').innerHTML = "";
$('tags').hide();
$('tags_collapsed').show();
+
+ $('recall_event').hide();
},
cancel: function() {
@@ -647,5 +675,145 @@ var Events = {
returnToCaller: function() {
window.location.href = Events.return_to;
+ },
+
+ recallEvent: function(url) {
+ if(!Events.recalledEvents) {
+ Events.loadRecalledEvents(url);
+ return;
+ }
+
+ if(Events.recalledEvents.length == 0) {
+ alert("No transactions matched the criteria you specified.");
+ return;
+ }
+
+ Events.currentEvent = (Events.currentEvent + 1) % Events.recalledEvents.length;
+ var event = Events.recalledEvents[Events.currentEvent].event;
+
+ Events.rehydrate(event);
+ },
+
+ loadRecalledEvents: function(url) {
+ parameters = 'page=0&size=10&actor=' + encodeURIComponent($F('event_actor_name'));
+ new Ajax.Request(url, {
+ asynchronous:true,
+ evalScripts:true,
+ method:'get',
+ parameters:parameters
+ });
+ },
+
+ doneLoadingRecalledEvents: function(events) {
+ Events.recalledEvents = events;
+ Events.currentEvent = -1;
+ Events.recallEvent();
+ },
+
+ rehydrate: function(event) {
+ var saved_date = $F('event_occurred_on');
+ var saved_actor = $F('event_actor_name');
+
+ Events.reset();
+
+ $('event_occurred_on').value = saved_date;
+ $('event_actor_name').value = saved_actor;
+ $('expense_total').value = Money.formatValue(event.value);
+ $('event_memo').value = event.memo;
+ if($('event_memo').present()) Events.revealMemo();
+
+ $('recall_event').show();
+
+ switch(event.role) {
+ case "expense":
+ Events.rehydrateExpenseEvent(event);
+ break;
+ case "deposit":
+ Events.rehydrateDepositEvent(event);
+ break;
+ case "transfer":
+ Events.rehydrateTransferEvent(event);
+ break;
+ case "reallocation":
+ alert("Not yet implemented: can't rehydrate bucket reallocations");
+ return;
+ default:
+ alert("Can't rehydrate '" + event.role + "' events");
+ return;
+ }
+
+ Events.rehydrateTagsForEvent(event);
+ },
+
+ rehydrateSection: function(section, event) {
+ var items = event.line_items.select(function(item) { return item.role == section; });
+ if(items.length == 0) return;
+
+ $(section).show();
+
+ var account = Events.accounts[items[0].account_id];
+ $('account_for_' + section).setValue(account.id);
+ if(account.role == "checking" && Events.sectionWantsCheckOptions(section)) {
+ $(section + '.check_options').show();
+ $('event_check_number').setValue(event.check_number);
+ }
+
+ Events.updateBucketsFor(section);
+
+ if(items.length == 1) {
+ var select = $(section + '.single_bucket').down('select');
+ Events.selectBucket(select, items[0].bucket_id);
+ } else {
+ $(section + '.multiple_buckets').show();
+ $(section + '.single_bucket').hide();
+
+ items.each(function(item) {
+ Events.addLineItemTo(section, item);
+ });
+ }
+ },
+
+ rehydrateExpenseEvent: function(event) {
+ Events.revealExpenseForm();
+ Events.rehydrateSection('payment_source', event);
+ Events.rehydrateSection('credit_options', event);
+ },
+
+ rehydrateDepositEvent: function(event) {
+ Events.revealDepositForm();
+ Events.rehydrateSection('deposit', event);
+ },
+
+ rehydrateTransferEvent: function(event) {
+ Events.revealTransferForm();
+ Events.rehydrateSection('transfer_from', event);
+ Events.rehydrateSection('transfer_to', event);
+ },
+
+ rehydrateTagsForEvent: function(event) {
+ var whole_tags = $A(), partial_tags = $A();
+
+ event.tagged_items.each(function(item) {
+ if(item.amount < event.value) {
+ partial_tags.push(item);
+ } else {
+ whole_tags.push(item);
+ }
+ });
+
+ if(whole_tags.length > 0 || partial_tags.length > 0) {
+ Events.revealTags();
+ if(whole_tags.length > 0) {
+ var tags = whole_tags.map(function(item) { return item.name }).join(", ")
+ $('tags').down('input').setValue(tags);
+ }
+
+ if(partial_tags.length > 0) {
+ Events.revealPartialTags(true);
+ partial_tags.each(function(item) {
+ Events.addTaggedItem(item);
+ });
+ }
+ }
}
}

0 comments on commit 290f324

Please sign in to comment.