Bugfixes: session management, new entry creation.

* Changed the cookie Path value to allow the cookie to be reused for the
  domain, not just ths `/ts_api` path. This allows the user to refresh the page
  and reuse their existing session as long as it is not stale.
* Fixed a bug in the `ts_json:ejson_to_record_strict/2` function. It was
  expecting a record out of `ts_json:ejson_to_record/2` but that function
  returns a tuple with the record and extended data. Because of the way
  `ejson_to_record_strict` uses case statements to check for specific values it
  was still passing the parsed record and data through, but all the checks were
  being bypassed.
* Fixed bugs in the `index.yaws` bootstrap code for the case where the user
  already has a valid session.
* Added `urlRoot` functions to the Backbone model definitions.
* Changed the behavior of the new entry creation method. We were trying to
  fetch just updated attributes from the server for the model we created, but
  we were pulling all the entries due to the URL backbone was using. This led
  to the new client-side model having all the previous entry models as
  attributes. Ideally we would fix the fetch so that only the one model is
  requested from the server, but we run into a catch-22 because the lookup id
  is not know by the client as it is generated on the server-side. For now I
  have changed this behavior so that we still pull all entries, but we pull
  them into the collection. The collection is then smart enough to update the
  entries that have changed (namely the new one). The server returns the newly
  created entry attributes in response to the POST request that the client
  makes initially, so when I have more time to work on this I plan to do away
  with the fetch after create, and just pull in the data from the server's
  response.
* Changed formatting.
This commit is contained in:
Jonathan Bernard 2013-10-22 15:32:22 +00:00
parent 0278179452
commit a3c55e918e
4 changed files with 145 additions and 215 deletions

View File

@ -190,7 +190,7 @@ do_login(YArg) ->
{CookieVal, _Session} = ts_api_session:new(Username),
[{header, {set_cookie, io_lib:format(
"ts_api_session=~s; Path=/ts_api",
"ts_api_session=~s; Path=/",
[CookieVal])}},
{header, ["Access-Control-Allow-Origin: ", get_origin_header(YArg)]},
{header, ["Access-Control-Allow-Credentials: ", "true"]},
@ -269,7 +269,7 @@ put_user(YArg, Username) ->
throw(make_json_400(YArg, [{request_error, InputError}]))
end,
% update the record (we do not support creating users via the API right now
% update the record (we do not support creating users via the API right now)
{ok, UpdatedRec} = ts_user:update(UR, ExtData),
% return a 200

View File

@ -81,31 +81,31 @@ ejson_to_record(Empty, {struct, EJSONFields}) ->
construct_record(Empty, EJSONFields, []).
ejson_to_record(Empty, Ref, EJSON) ->
Constructed = ejson_to_record(Empty, EJSON),
setelement(2, Constructed, Ref).
{Constructed, ExtData} = ejson_to_record(Empty, EJSON),
{setelement(2, Constructed, Ref), ExtData}.
ejson_to_record_strict(Empty=#ts_user{}, EJSON) ->
Constructed = ejson_to_record(Empty, EJSON),
{Constructed, ExtData} = ejson_to_record(Empty, EJSON),
case Constructed of
#ts_user{name = undefined} -> throw("Missing user 'name' field.");
#ts_user{email = undefined} -> throw("Missing user 'email' field.");
#ts_user{join_date = undefined} ->
throw("Missing user 'join_date' field.");
_Other -> Constructed
_Other -> {Constructed, ExtData}
end;
ejson_to_record_strict(Empty=#ts_timeline{}, EJSON) ->
Constructed = ejson_to_record(Empty, EJSON),
{Constructed, ExtData} = ejson_to_record(Empty, EJSON),
case Constructed of
#ts_timeline{created = undefined} ->
throw("Missing timeline 'created' field.");
#ts_timeline{desc = undefined} ->
throw("Missing timeline 'description' field.");
_Other -> Constructed
_Other -> {Constructed, ExtData}
end;
ejson_to_record_strict(Empty=#ts_entry{}, EJSON) ->
Constructed = ejson_to_record(Empty, EJSON),
{Constructed, ExtData} = ejson_to_record(Empty, EJSON),
case Constructed of
#ts_entry{timestamp = undefined} ->
throw("Missing timelne 'timestamp' field.");
@ -113,12 +113,12 @@ ejson_to_record_strict(Empty=#ts_entry{}, EJSON) ->
throw("Missing timeline 'mark' field.");
#ts_entry{notes = undefined} ->
throw("Missing timeline 'notes' field/");
_Other -> Constructed
_Other -> {Constructed, ExtData}
end.
ejson_to_record_strict(Empty, Ref, EJSON) ->
Constructed = ejson_to_record_strict(Empty, EJSON),
setelement(2, Constructed, Ref).
{Constructed, ExtData} = ejson_to_record_strict(Empty, EJSON),
{setelement(2, Constructed, Ref), ExtData}.
construct_record(User=#ts_user{}, [{Key, Value}|Fields], ExtData) ->
case Key of

View File

@ -28,33 +28,38 @@ out(YArg) ->
Username = element(2, Session),
% get the user
{content, _, UserJSON} = ts_api:get_user(YArg, Username),
UserResp = ts_api:get_user(YArg, Username),
{content, _, UserJSON} = lists:keyfind(content, 1, UserResp),
UserRecord = ts_user:lookup(Username),
% get the timelines
{content, _, TimelineListJSON} = ts_api:list_timelines(YArg, Username),
TimelineResp = ts_api:list_timelines(YArg, Username),
{content, _, TimelineListJSON} = lists:keyfind(content, 1, TimelineResp),
% get the selected timeline
SelectedTimeline = case lists:keyfind(
selected_timeline, 1, element(8, UserRecord)) of
false -> ts_timeline:list(Username, 0, 1);
% get the last used timeline if there is one.
SelectedTimeline = case ts_ext_data:get_property(Username, last_timeline) of
not_set -> ts_timeline:list(Username, 0, 1);
T -> T
end,
% get entries for this timeline
{content, _, EntryListJSON} =
EntriesResp =
ts_api:list_entries(YArg, Username, SelectedTimeline),
{content, _, EntryListJSON} = lists:keyfind(content, 1, EntriesResp),
{html, f(
"function bootstrap() {~n"
" var data = {};~n"
" data.user = ~p;~n"
" data.timelines = ~p;~n"
" data.user = ~s;~n"
" data.timelines = ~s;~n"
" data.initialTimelineId = ~p;~n"
" data.entries = ~p;~n"
" data.entries = ~s;~n"
" return data;~n"
"};",
[UserJSON, TimelineListJSON, SelectedTimeline, EntryListJSON])}
[lists:flatten(UserJSON), lists:flatten(TimelineListJSON),
SelectedTimeline, lists:flatten(EntryListJSON)])}
end.
</erl>
</script>

View File

@ -1,3 +1,8 @@
/**
* # `ts.js`: TimeStamper Web UI Client-side
* @author Jonathan Bernard <jdb@jdb-labs.com>
**/
// TimeStamper namespace
var TS = {};
@ -8,24 +13,27 @@ $(document).ready(function(){
/* Entry model.
* Attributes
* - user_id
* - timeline_id
* - id
* - mark
* - uuid
* - notes
* - timestamp
*/
TS.EntryModel = Backbone.Model.extend({
initialize: function(attrs, options) {
_.bindAll(this, 'get', 'set', 'urlRoot'); },
get: function(attribute) {
if (attribute == "timestamp") {
if (!this.timestampDate) {
this.timestampDate = new Date(
Backbone.Model.prototype.get.call(this, attribute));
}
return this.timestampDate;
} else {
return Backbone.Model.prototype.get.call(this, attribute);
}
},
Backbone.Model.prototype.get.call(this, attribute)); }
return this.timestampDate; }
else {
return Backbone.Model.prototype.get.call(this, attribute); } },
set: function(attributes, options) {
var attrsToSet = {}
@ -33,44 +41,42 @@ $(document).ready(function(){
if (key == "timestamp") {
if (val instanceof Date) {
this.timestampDate = val;
attrsToSet.timestamp = dateToJSON(val);
} else {
attrsToSet.timestamp = dateToJSON(val); }
else {
this.timestampDate = new Date(val);
attrsToSet.timestamp = dateToJSON(this.timestampDate);
}
} else {
attrsToSet[key] = val;
}
});
attrsToSet.timestamp = dateToJSON(this.timestampDate); } }
else { attrsToSet[key] = val; } });
return Backbone.Model.prototype.set.call(this, attrsToSet, options);
}
});
return Backbone.Model.prototype.set.call(
this, attrsToSet, options); },
urlRoot: function() { return '/ts_api/entries/' + this.get('user_id') +
'/' + this.get('timeline_id') + '/'; } });
/* Timeline model.
* Attributes:
* - user_id
* - id
* - description
* - created
*/
TS.TimelineModel = Backbone.Model.extend({
urlRoot: function() {
return '/ts_api/timelines/' + this.get('user_id') + '/'; },
});
initialze: function(attrs, options) { _.bindAll(this, 'urlRoot'); } });
/* User model.
* Attributes:
* - username
* - fullname
* - id
* - name
* - email
* - join_date
*/
TS.UserModel = Backbone.Model.extend({
url: function() { return '/ts_api/users/' + this.get('id'); },
initialize: function(attrs, options) {
_.bind(this, 'url');
}
});
initialize: function(attrs, options) { _.bindAll(this, 'url'); } });
TS.EntryList = Backbone.Collection.extend({
model: TS.EntryModel,
@ -79,17 +85,23 @@ $(document).ready(function(){
initialize: function(model, options) {
if (options.timelineModel == undefined) {
throw "Cannot create an EntryList without a TimelineModel reference."
} else { this.timelineModel = options.timelineModel; }
throw "Cannot create an EntryList without a " +
"TimelineModel reference." }
else { this.timelineModel = options.timelineModel; }
_.bindAll(this, "url");
},
_.bindAll(this, "url", "create"); },
/*create: function(model, options) {
if (!(model instanceof Backbone.Model)) {
model.user_id = this.timelineModel.get('user_id');
model.timeline_id = this.timelineModel.get('timeline_id'); }
else {
model.set('user_id') = this.timelineModel.get('user_id')
},*/
url: function() {
return "/ts_api/entries/" + this.timelineModel.get('user_id') + "/"
+ this.timelineModel.get('id');
}
});
+ this.timelineModel.get('id'); } });
TS.TimelineList = Backbone.Collection.extend({
model: TS.TimelineModel,
@ -99,17 +111,12 @@ $(document).ready(function(){
throw "Cannot create a TimelineList without a UserModel reference.";
} else { this.user = options.user; }
_.bindAll(this, 'url');
},
_.bindAll(this, 'url'); },
comparator: function(timeline) {
return timeline.get('id');
},
comparator: function(timeline) { return timeline.get('id'); },
url: function() {
return "/ts_api/timelines/" + this.user.get('id');
}
});
return "/ts_api/timelines/" + this.user.get('id'); } });
// ======== DEFINE VIEWS ========//
@ -135,8 +142,7 @@ $(document).ready(function(){
"keypress .notes-input" : "saveOnCtrlEnter",
"blur .mark-input" : "save",
"blur .timestamp-input" : "save",
"blur .notes-input" : "save"
},
"blur .notes-input" : "save" },
initialize: function(options) {
_.bindAll(this, 'render', 'editTImestamp', 'editMark', 'update',
@ -148,8 +154,7 @@ $(document).ready(function(){
this.model.bind('change', this.update);
this.model.view = this;
this.nextModel = options.nextModel;
},
this.nextModel = options.nextModel; },
/**
* Refresh the display based on the model replacing the existing
@ -162,8 +167,7 @@ $(document).ready(function(){
// invalidate the notes display cache
this.notesCache = false;
return this;
},
return this; },
/**
* Refresh the display based on the model using the existing DOM
@ -178,27 +182,23 @@ $(document).ready(function(){
this.$('.duration').text(data.duration);
this.$('.notes-text').html(this.renderNotes(data.notes));
this.$('.notes-input').val(data.notes);
return this;
},
return this; },
renderNotes: function(source) {
if (!this.notesCache) {
this.notesCache = this.markdownConverter.makeHtml(source); }
return this.notesCache
},
return this.notesCache },
editMark: function() {
$(this.el).addClass('edit-mark');
this.$('.mark-input').focus();
return this;
},
return this; },
editTimestamp: function() {
$(this.el).addClass('edit-timestamp');
this.$('timestamp-input').focus();
return this;
},
return this; },
editNotes: function() {
// invalidate notes HTML cache
@ -209,8 +209,7 @@ $(document).ready(function(){
// focus input
this.$('.notes-input').focus();
return this;
},
return this; },
/**
* Translate the model data into a form suitable to be displayed.
@ -224,8 +223,7 @@ $(document).ready(function(){
data.start = this.formatStart(tsDate);
data.duration = this.formatDuration(this.model, this.nextModel);
data.notes = data.notes ? data.notes : '*No notes for this entry.*';
return data;
},
return data; },
/** Save and close editable fields. */
save: function() {
@ -235,17 +233,12 @@ $(document).ready(function(){
notes: this.$('.notes-input').val()});
this.$('.notes-text').html(this.renderNotes(this.model.get('notes')));
$(this.el).removeClass('edit-mark edit-timestamp edit-notes');
},
$(this.el).removeClass('edit-mark edit-timestamp edit-notes'); },
/** Event handler for keypresses on entry input fields. */
saveOnEnter: function(e) {
if(e.keyCode == 13) { this.save(); }
},
saveOnEnter: function(e) { if(e.keyCode == 13) { this.save(); } },
saveOnCtrlEnter: function(e) {
if (e.keyCode == 10) { this.save(); }
},
saveOnCtrlEnter: function(e) { if (e.keyCode == 10) { this.save(); } },
/**
* Get the display-able start time from the entry timestamp.
@ -263,8 +256,7 @@ $(document).ready(function(){
// 12 hour
var hour = startDate.getHours() % 12;
return (hour == 0 ? 12 : hour) + ":" + startDate.getMinutes() +
" " + (startDate.getHours() > 11 ? "pm" : "am");
},
" " + (startDate.getHours() > 11 ? "pm" : "am"); },
/**
* Get the duration of the entry based on this entry's timestamp and
@ -296,8 +288,7 @@ $(document).ready(function(){
return (day > 0 ? day + "d " : "") +
(hr > 0 ? hr + "hr " : "") +
min + "m ";
},
min + "m "; },
showNotes: function() {
if (!this.notesCache) {
@ -305,22 +296,17 @@ $(document).ready(function(){
this.renderNotes(this.model.get('notes'))); }
this.$('.notes').slideDown();
$(this.el).addClass('show-notes');
},
$(this.el).addClass('show-notes'); },
hideNotes: function() {
this.$('.notes').slideUp();
$(this.el).removeClass('show-notes');
}
});
$(this.el).removeClass('show-notes'); } });
TS.EntryListView = Backbone.View.extend({
el: $("#entry-list"),
events: {
"keypress #new-entry-input" : "createNewEntryOnEnter"
},
events: { "keypress #new-entry-input" : "createNewEntryOnEnter" },
initialize: function() {
_.bindAll(this, 'addOne', 'createNewEntry', 'render', 'renderOne');
@ -328,15 +314,13 @@ $(document).ready(function(){
this.collection.bind('refresh', this.render);
this.collection.view = this;
this.entryContainer = this.$("#entries")
this.markdownConverter = new Showdown.converter();
},
this.markdownConverter = new Showdown.converter(); },
addOne: function(entry) {
var lastEntry = this.collection.at(this.collection.length - 2);
lastEntry.view.nextModel = entry;
lastEntry.view.update();
this.renderOne(entry, null);
},
this.renderOne(entry, null); },
renderOne: function(entry, nextEntry) {
// exclude if any exclusion RegExps match
@ -357,9 +341,7 @@ $(document).ready(function(){
// hide it if excluded
if (excluded) {
$(el).fadeOut('slow');
$(el).addClass('excluded');
}
},
$(el).addClass('excluded'); } },
createNewEntryOnEnter: function(e) {
@ -371,13 +353,12 @@ $(document).ready(function(){
// create the mark. Immediately fetch to get server-side timestamp
this.collection.create({mark: entryMark,
notes: '',
timestamp: new Date()}).fetch();
timestamp: new Date()});
this.collection.fetch();
// clear the input for the next entry
this.$("#new-entry-input").val("");
}
},
this.$("#new-entry-input").val(""); } },
render: function() {
@ -416,12 +397,9 @@ $(document).ready(function(){
if (currentDay.getDate() != entry.get("timestamp").getDate()) {
this.topSeparator.after(ich.daySeparatorTemplate(
{separatorLabel: this.formatDaySeparator(today, currentDay)}));
currentDay = entry.get('timestamp');
}
currentDay = entry.get('timestamp'); }
this.renderOne(entry, nextEntry);
}
},
this.renderOne(entry, nextEntry); } },
formatDaySeparator: function(today, labelDay) {
@ -456,10 +434,7 @@ $(document).ready(function(){
else if (dayDiff == 1) { return "Yesterday"; }
// today
else if (dayDiff == 0) { return "Today"; }
}
});
else if (dayDiff == 0) { return "Today"; } } });
TS.TimelineListView = Backbone.View.extend({
el: $("#timeline"),
@ -471,65 +446,53 @@ $(document).ready(function(){
"dblclick .timeline-desc" : "editDesc",
"keypress .timeline-id-input" : "saveOnEnter",
"keypress .timeline-desc-input" : "saveOnEnter",
"click .new-timeline-link" : "showNewTimelineDialog"
},
"click .new-timeline-link" : "showNewTimelineDialog" },
initialize: function(options) {
_.bindAll(this, 'render', 'renderOne', 'editId',
'editDesc', 'saveOnEnter', 'save');
if (options.initialTimelineId == undefined) {
throw "Can not create a TimelineListView without an initial timeline."
} else {
this.selectedModel = this.collection.get(options.initialTimelineId);
}
throw "Can not create a TimelineListView without an " +
"initial timeline." }
else {
this.selectedModel =
this.collection.get(options.initialTimelineId); }
this.collection.bind('add', this.renderOne);
this.collection.bind('refresh', this.render);
},
this.collection.bind('refresh', this.render); },
renderOne: function(timeline) {
this.$('.drop-menu-items').append(
ich.timelineLinkTemplate(timeline.toJSON()));
},
ich.timelineLinkTemplate(timeline.toJSON())); },
render: function() {
// render the basic template
$(this.el).html(ich.timelineTemplate(this.selectedModel.toJSON()));
// render the selection list
_.each(this.collection.without([this.selectedModel]), this.renderOne);
},
_.each(this.collection.without([this.selectedModel]), this.renderOne); },
editId: function() {
$(this.el).addClass('edit-id');
this.$('.timeline-id-input').focus();
return this;
},
return this; },
editDesc: function() {
$(this.el).addClass('edit-desc');
this.$('.timeline-desc-input').focus();
return this;
},
return this; },
save: function() {
this.selectedModel.save({
id: this.$('.timeline-id-input').val(),
description: this.$('.timeline-desc-input').val()});
$(this.el).removeClass('edit-id edit-desc');
this.render();
},
this.render(); },
showNewTimelineDialog: function() {
TS.app.newTimelineDialog.show();
},
showNewTimelineDialog: function() { TS.app.newTimelineDialog.show(); },
saveOnEnter: function(e) {
if (e.keyCode == 13) { this.save(); }
}
});
saveOnEnter: function(e) { if (e.keyCode == 13) { this.save(); } } });
TS.UserView = Backbone.View.extend({
@ -539,44 +502,34 @@ $(document).ready(function(){
events: {
'dblclick .fullname': 'editFullname',
'keypress .fullname-input': 'saveOnEnter'
},
'keypress .fullname-input': 'saveOnEnter' },
initialize: function() {
_.bindAll(this, 'render', 'save', 'editFullname', 'saveOnEnter');
this.model.bind('change', this.render);
this.model.view = this;
},
this.model.view = this; },
render: function() {
$(this.el).html(ich.userTemplate(this.model.toJSON()));
return this;
},
return this; },
editFullname: function() {
$(this.el).addClass('edit-fullname');
this.$('.fullname-input').focus();
return this;
},
return this; },
save: function() {
this.model.set({name: this.$('fullname-input').val()});
this.model.save();
$(this.el).removeClass('edit-fullname');
},
$(this.el).removeClass('edit-fullname'); },
saveOnEnter: function(e) {
if (e.keyCode == 13) this.save();
}
});
saveOnEnter: function(e) { if (e.keyCode == 13) this.save(); } });
TS.AppView = Backbone.View.extend({
el: $("body"),
events: {
'click #timeline .drop-menu-items a': 'selectTimeline'
},
events: { 'click #timeline .drop-menu-items a': 'selectTimeline' },
initialize: function() {
@ -593,12 +546,12 @@ $(document).ready(function(){
else {
// this is async (waiting for user input)
this.loginDialog.authenticate(function() {
appThis.initializeData(appThis.loadInitialData())});
}
},
appThis.initializeData(appThis.loadInitialData())}); } },
initializeData: function(data) {
TS.app = this;
// create user data
this.user = {};
this.user.model = new TS.UserModel(data.user);
@ -606,8 +559,7 @@ $(document).ready(function(){
// create timeline models from the bootstrapped data
var tlModels = _.map(data.timelines, function(timeline) {
return new TS.TimelineModel(timeline);
});
return new TS.TimelineModel(timeline); });
// create the timeline list collection
this.timelines = {};
@ -623,8 +575,7 @@ $(document).ready(function(){
// create entry models from the bootstrapped data
var entryModels = _.map(data.entries, function(entry) {
return new TS.EntryModel(entry);
});
return new TS.EntryModel(entry); });
// create the entry collection
this.entries = {};
@ -636,10 +587,7 @@ $(document).ready(function(){
// render views
this.user.view.render();
this.timelines.view.render();
this.entries.view.render();
},
this.entries.view.render(); },
loadInitialData: function() {
// assume we are authenticated
@ -658,8 +606,7 @@ $(document).ready(function(){
data.initialTimelineId,
async: false}).responseText);
return data;
},
return data; },
selectTimeline: function(e) {
if (e) {
@ -680,32 +627,24 @@ $(document).ready(function(){
this.timelines.view.render();
// refresh EntryList records
this.entries.collection.fetch()
}
}
});
this.entries.collection.fetch() } } });
TS.LoginView = Backbone.View.extend({
el: $("#login"),
events: {
"keypress #password-input" : "loginOnEnter",
"click #login-button a" : "doLogin"
},
"click #login-button a" : "doLogin" },
initialize: function() {
_.bindAll(this, 'authenticate', 'doLogin', 'hide', 'loginOnEnter',
'show');
},
'show'); },
action: function() {},
authenticate: function(nextAction) {
this.action = nextAction;
this.show();
},
this.show(); },
doLogin: function(){
var viewThis = this;
@ -725,46 +664,35 @@ $(document).ready(function(){
// we should check that, FIXME
var tips = $(".validate-tips");
tips.text("Incorrect username/password combination.");
tips.slideDown();
},
tips.slideDown(); },
success: function(data, textStatus, jqXHR) {
viewThis.hide();
viewThis.action();
}
});
},
viewThis.action(); } }); },
hide: function() { $(this.el).addClass('hidden'); },
show: function() {
$(this.el).removeClass('hidden');
this.$("#username-input").focus();
},
this.$("#username-input").focus(); },
loginOnEnter: function(e) {
if (e.keyCode == 13) { this.doLogin(); }
}
});
if (e.keyCode == 13) { this.doLogin(); } } });
TS.NewTimelineView = Backbone.View.extend({
el: $("#new-timeline"),
events: {
"click #new-timeline-create a" : "createTimeline",
"click #new-timeline-cancel a" : "hide"
},
"click #new-timeline-cancel a" : "hide" },
initialize: function(options) {
_.bindAll(this, 'createTimeline', 'hide', 'show');
if (options.timelineCollection == undefined) {
throw "Can not create the NewTimelineView without the timeline collection."
} else {
this.timelineCollection = options.timelineCollection;
}
},
throw "Can not create the NewTimelineView without the " +
"timeline collection." }
else { this.timelineCollection = options.timelineCollection; } },
createTimeline: function() {
var timelineId = this.$("#new-timeline-id").val();
@ -772,8 +700,7 @@ $(document).ready(function(){
this.timelineCollection.create(
{id: timelineId, description: timelineDesc,
created: dateToJSON(new Date())});
this.hide();
},
this.hide(); },
hide: function() { $(this.el).addClass('hidden'); },
@ -781,11 +708,9 @@ $(document).ready(function(){
this.$("#new-timeline-id").val("");
this.$("#new-timeline-desc").val("");
$(this.el).removeClass('hidden');
this.$("#new-timeline-id").focus();
}
});
this.$("#new-timeline-id").focus(); } });
TS.app = new TS.AppView;
new TS.AppView();
})