From d644cba38ff06369cc43c1ebd08d344fd1d248ea Mon Sep 17 00:00:00 2001 From: Maxime Quandalle Date: Mon, 31 Aug 2015 15:09:53 +0200 Subject: 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 --- client/lib/escapeActions.js | 27 +++++++-------- client/lib/inlinedform.js | 78 ++++++++++++++++++++++++++++++++++++++++++ client/lib/unsavedEdits.js | 82 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 client/lib/inlinedform.js create mode 100644 client/lib/unsavedEdits.js (limited to 'client/lib') diff --git a/client/lib/escapeActions.js b/client/lib/escapeActions.js index fa113bf3..ff793b1d 100644 --- a/client/lib/escapeActions.js +++ b/client/lib/escapeActions.js @@ -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; diff --git a/client/lib/inlinedform.js b/client/lib/inlinedform.js new file mode 100644 index 00000000..15074f40 --- /dev/null +++ b/client/lib/inlinedform.js @@ -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' + } +); diff --git a/client/lib/unsavedEdits.js b/client/lib/unsavedEdits.js new file mode 100644 index 00000000..55ea2529 --- /dev/null +++ b/client/lib/unsavedEdits.js @@ -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 }); +}); -- cgit v1.2.3-1-g7c22