Replace the component bounded cachedValue by a global UnsavedEdits

This new draft saving system is currently only implemented for the
card description and comment. We need better a component
inheritance/composition model to support this for all editable fields.

Fixes #186
This commit is contained in:
Maxime Quandalle 2015-08-31 15:09:53 +02:00
parent cc88e78483
commit d644cba38f
13 changed files with 252 additions and 95 deletions

View file

@ -17,10 +17,10 @@ EscapeActions = {
'inlinedForm',
'detailsPane',
'multiselection',
'sidebarView'
'sidebarView',
],
register: function(label, action, condition = () => true, options = {}) {
register(label, action, condition = () => true, options = {}) {
const priority = this.hierarchy.indexOf(label);
if (priority === -1) {
throw Error('You must define the label in the EscapeActions hierarchy');
@ -33,35 +33,35 @@ EscapeActions = {
let noClickEscapeOn = options.noClickEscapeOn;
this._actions[priority] = {
this._actions = _.sortBy([...this._actions, {
priority,
condition,
action,
noClickEscapeOn,
enabledOnClick
};
enabledOnClick,
}], (action) => action.priority);
},
executeLowest: function() {
executeLowest() {
return this._execute({
multipleAction: false
});
},
executeAll: function() {
executeAll() {
return this._execute({
multipleActions: true
});
},
executeUpTo: function(maxLabel) {
executeUpTo(maxLabel) {
return this._execute({
maxLabel: maxLabel,
multipleActions: true
});
},
clickExecute: function(target, maxLabel) {
clickExecute(target, maxLabel) {
if (this._nextclickPrevented) {
this._nextclickPrevented = false;
} else {
@ -74,18 +74,18 @@ EscapeActions = {
}
},
preventNextClick: function() {
preventNextClick() {
this._nextclickPrevented = true;
},
_stopClick: function(action, clickTarget) {
_stopClick(action, clickTarget) {
if (! _.isString(action.noClickEscapeOn))
return false;
else
return $(clickTarget).closest(action.noClickEscapeOn).length > 0;
},
_execute: function(options) {
_execute(options) {
const maxLabel = options.maxLabel;
const multipleActions = options.multipleActions;
const isClick = !! options.isClick;
@ -99,8 +99,7 @@ EscapeActions = {
else
maxPriority = this.hierarchy.indexOf(maxLabel);
for (let i = 0; i < this._actions.length; i++) {
let currentAction = this._actions[i];
for (let currentAction of this._actions) {
if (currentAction.priority > maxPriority)
return executedAtLeastOne;

78
client/lib/inlinedform.js Normal file
View file

@ -0,0 +1,78 @@
// A inlined form is used to provide a quick edition of single field for a given
// document. Clicking on a edit button should display the form to edit the field
// value. The form can then be submited, or just closed.
//
// When the form is closed we save non-submitted values in memory to avoid any
// data loss.
//
// Usage:
//
// +inlineForm
// // the content when the form is open
// else
// // the content when the form is close (optional)
// We can only have one inlined form element opened at a time
currentlyOpenedForm = new ReactiveVar(null);
InlinedForm = BlazeComponent.extendComponent({
template: function() {
return 'inlinedForm';
},
onCreated: function() {
this.isOpen = new ReactiveVar(false);
},
onDestroyed: function() {
currentlyOpenedForm.set(null);
},
open: function() {
// Close currently opened form, if any
EscapeActions.executeUpTo('inlinedForm');
this.isOpen.set(true);
currentlyOpenedForm.set(this);
},
close: function() {
this.isOpen.set(false);
currentlyOpenedForm.set(null);
},
getValue: function() {
var input = this.find('textarea,input[type=text]');
return this.isOpen.get() && input && input.value;
},
events: function() {
return [{
'click .js-close-inlined-form': this.close,
'click .js-open-inlined-form': this.open,
// Pressing Ctrl+Enter should submit the form
'keydown form textarea': function(evt) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
this.find('button[type=submit]').click();
}
},
// Close the inlined form when after its submission
submit: function() {
if (this.currentData().autoclose !== false) {
Tracker.afterFlush(() => {
this.close();
});
}
}
}];
}
}).register('inlinedForm');
// Press escape to close the currently opened inlinedForm
EscapeActions.register('inlinedForm',
function() { currentlyOpenedForm.get().close(); },
function() { return currentlyOpenedForm.get() !== null; }, {
noClickEscapeOn: '.js-inlined-form'
}
);

View file

@ -0,0 +1,82 @@
Meteor.subscribe('unsaved-edits');
// `UnsavedEdits` is a global key-value store used to save drafts of user
// inputs. We used to have the notion of a `cachedValue` that was local to a
// component but the global store has multiple advantages:
// 1. When the component is unmounted (ie, destroyed) the draft isn't lost
// 2. The drafts are synced across multiple computers
// 3. The drafts are synced across multiple browser tabs
// XXX This currently doesn't work in purely offline mode since the sync is
// handled with the DDP connection to the server. To solve this, we could use
// something like GroundDB that syncs using localstorage.
//
// The key is a dictionary composed of two fields:
// * a `fieldName` which identifies the particular field. Since this is a global
// identifier a good practice would be to compose it with the collection name
// and the document field, eg. `boardTitle`, `cardDescription`.
// * a `docId` which identifies the appropriate document. In general we use
// MongoDB `_id` field.
//
// The value is a string containing the draft.
UnsavedEdits = {
// XXX Wanted to have the collection has an instance variable, but
// unfortunately the collection isn't defined yet at this point. We need ES6
// modules to solve the file order issue!
//
// _collection: UnsavedEditCollection,
get({ fieldName, docId }, defaultTo = '') {
let unsavedValue = this._getCollectionDocument(fieldName, docId);
if (unsavedValue) {
return unsavedValue.value
} else {
return defaultTo;
}
},
has({ fieldName, docId }) {
return Boolean(this.get({fieldName, docId}));
},
set({ fieldName, docId }, value) {
let currentDoc = this._getCollectionDocument(fieldName, docId);
if (currentDoc) {
UnsavedEditCollection.update(currentDoc._id, {
$set: {
value: value
}
});
} else {
UnsavedEditCollection.insert({
fieldName,
docId,
value,
});
}
},
reset({ fieldName, docId }) {
let currentDoc = this._getCollectionDocument(fieldName, docId);
if (currentDoc) {
UnsavedEditCollection.remove(currentDoc._id);
}
},
_getCollectionDocument(fieldName, docId) {
return UnsavedEditCollection.findOne({fieldName, docId});
}
}
Blaze.registerHelper('getUnsavedValue', (fieldName, docId, defaultTo) => {
// Workaround some blaze feature that ass a list of keywords arguments as the
// last parameter (even if the caller didn't specify any).
if (! _.isString(defaultTo)) {
defaultTo = '';
}
return UnsavedEdits.get({ fieldName, docId }, defaultTo);
});
Blaze.registerHelper('hasUnsavedValue', (fieldName, docId) => {
return UnsavedEdits.has({ fieldName, docId });
});