From aaf676e2e19279226964db9f5badcc55710406fc Mon Sep 17 00:00:00 2001 From: Cory Date: Thu, 25 Aug 2016 15:29:48 -0400 Subject: [PATCH] Implement Atom `save` hook Save hook is on the atom's `env` and accepts arguments: `(value, payload)`. Calling the save hook rerenders the atom. Example: ``` let atom = { name: 'my-atom', type: 'dom', render({env, value, payload}) { let el = document.createElement('button'); let clicks = payload.clicks || 0; el.appendChild(document.createTextNode('Clicks: ' + clicks)); el.onclick = () => { payload.clicks = payload.clicks || 0; payload.clicks++; env.save(value, payload); }; return el; } }; ``` Also: improve postAbstract buildFromText to accept data for an atom, i.e. "abc@(...jsondata...)", e.g.: ``` buildFromText('abc@("name": "my-atom", "value": "bob", "payload": {"foo": "bar"})def'); // -> "abc" + atom with name "my-atom", value "bob", payload {foo: 'bar'} + "def" ``` Fixes #399 --- ATOMS.md | 21 +++++++++++ src/js/models/atom-node.js | 10 +++++- src/js/models/post-node-builder.js | 6 ++-- tests/helpers/post-abstract.js | 39 +++++++++++++++++++-- tests/unit/editor/atom-lifecycle-test.js | 44 ++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 7 deletions(-) diff --git a/ATOMS.md b/ATOMS.md index 0961f28d5..ef055989e 100644 --- a/ATOMS.md +++ b/ATOMS.md @@ -29,6 +29,7 @@ must be of the correct type (a DOM Node for the dom renderer, a string of html o * `name` [string] - the name of the atom * `onTeardown` [function] - The atom can pass a callback function: `onTeardown(callbackFn)`. The callback will be called when the rendered content is torn down. + * `save` [function] - Call this function with the arguments `(newValue, newPayload)` to update the atom's value and payload and rerender it. ## Atom Examples @@ -56,3 +57,23 @@ let atom = { } }; ``` + +Example dom atom that uses the `save` hook: +```js +let atom = { + name: 'click-counter', + type: 'dom', + render({env, value, payload}) { + let clicks = payload.clicks || 0; + let button = document.createElement('button'); + button.appendChild(document.createTextNode('Clicks: ' + clicks)); + + button.onclick = () => { + payload.clicks = clicks + 1; + env.save(value, payload); // updates payload.clicks, rerenders button + }; + + return button; + } +}; +``` diff --git a/src/js/models/atom-node.js b/src/js/models/atom-node.js index f15e1485b..58e7f409c 100644 --- a/src/js/models/atom-node.js +++ b/src/js/models/atom-node.js @@ -25,7 +25,15 @@ export default class AtomNode { get env() { return { name: this.atom.name, - onTeardown: (callback) => this._teardownCallback = callback + onTeardown: (callback) => this._teardownCallback = callback, + save: (value, payload={}) => { + this.model.value = value; + this.model.payload = payload; + + this.editor._postDidChange(); + this.teardown(); + this.render(); + } }; } diff --git a/src/js/models/post-node-builder.js b/src/js/models/post-node-builder.js index 9f61acfbb..2ba5e7b21 100644 --- a/src/js/models/post-node-builder.js +++ b/src/js/models/post-node-builder.js @@ -134,13 +134,13 @@ class PostNodeBuilder { /** * @param {String} name - * @param {String} [text=''] + * @param {String} [value=''] * @param {Object} [payload={}] * @param {Markup[]} [markups=[]] * @return {Atom} */ - createAtom(name, text='', payload={}, markups=[]) { - const atom = new Atom(name, text, payload, markups); + createAtom(name, value='', payload={}, markups=[]) { + const atom = new Atom(name, value, payload, markups); atom.builder = this; return atom; } diff --git a/tests/helpers/post-abstract.js b/tests/helpers/post-abstract.js index 9807dccb3..d45273047 100644 --- a/tests/helpers/post-abstract.js +++ b/tests/helpers/post-abstract.js @@ -66,6 +66,7 @@ function parsePositionOffsets(text) { } const DEFAULT_ATOM_NAME = 'some-atom'; +const DEFAULT_ATOM_VALUE = '@atom'; function parseTextIntoMarkers(text, builder) { text = text.replace(cursorRegex,''); @@ -73,8 +74,38 @@ function parseTextIntoMarkers(text, builder) { if (text.indexOf('@') !== -1) { let atomIndex = text.indexOf('@'); - let atom = builder.atom(DEFAULT_ATOM_NAME); - let pieces = [text.slice(0, atomIndex), atom, text.slice(atomIndex+1)]; + let afterAtomIndex = atomIndex + 1; + let atomName = DEFAULT_ATOM_NAME, + atomValue = DEFAULT_ATOM_VALUE, + atomPayload = {}; + + // If "@" is followed by "( ... json ... )", parse the json data + if (text[atomIndex+1] === "(") { + let jsonStartIndex = atomIndex+1; + let jsonEndIndex = text.indexOf(")",jsonStartIndex); + afterAtomIndex = jsonEndIndex + 1; + if (jsonEndIndex === -1) { + throw new Error('Atom JSON data had unmatched "(": ' + text); + } + let jsonString = text.slice(jsonStartIndex+1, jsonEndIndex); + jsonString = "{" + jsonString + "}"; + try { + let json = JSON.parse(jsonString); + if (json.name) { atomName = json.name; } + if (json.value) { atomValue = json.value; } + if (json.payload) { atomPayload = json.payload; } + } catch(e) { + throw new Error('Failed to parse atom JSON data string: ' + jsonString + ', ' + e); + } + } + + // create the atom + let atom = builder.atom(atomName, atomValue, atomPayload); + + // recursively parse the remaining text pieces + let pieces = [text.slice(0, atomIndex), atom, text.slice(afterAtomIndex)]; + + // join the markers together pieces.forEach(piece => { if (piece === atom) { markers.push(piece); @@ -151,7 +182,9 @@ function parseSingleText(text, builder) { * Use "|" to indicate the cursor position or "<" and ">" to indicate a range. * Use "[card-name]" to indicate a card * Use asterisks to indicate bold text: "abc *bold* def" - * Use "@" to indicate an atom + * Use "@" to indicate an atom, default values for name,value,payload are DEFAULT_ATOM_NAME,DEFAULT_ATOM_VALUE,{} + * Use "@(name, value, payload)" to specify name,value and/or payload for an atom. The string from `(` to `)` is parsed as + * JSON, e.g.: '@("name": "my-atom", "value": "abc", "payload": {"foo": "bar"})' -> atom named "my-atom" with value 'abc', payload {foo: 'bar'} * Use "* " at the start of the string to indicate a list item ("ul") * * Examples: diff --git a/tests/unit/editor/atom-lifecycle-test.js b/tests/unit/editor/atom-lifecycle-test.js index 5f35435d4..fce39ffd3 100644 --- a/tests/unit/editor/atom-lifecycle-test.js +++ b/tests/unit/editor/atom-lifecycle-test.js @@ -316,3 +316,47 @@ test('mutating the content of an atom does not trigger an update', (assert) => { done(); }); }); + +test('atom env has "save" method, rerenders atom', (assert) => { + let atomArgs = {}; + let render = 0; + let teardown = 0; + let postDidChange = 0; + let save; + + const atom = { + name: DEFAULT_ATOM_NAME, + type: 'dom', + render({env, value, payload}) { + render++; + atomArgs.value = value; + atomArgs.payload = payload; + save = env.save; + + env.onTeardown(() => teardown++); + + return makeEl('the-atom', value); + } + }; + + editor = Helpers.editor.buildFromText('abc|@("value": "initial-value", "payload": {"foo": "bar"})def', {autofocus: true, atoms:[atom], element: editorElement}); + editor.postDidChange(() => postDidChange++); + + assert.equal(render, 1, 'precond - renders atom'); + assert.equal(teardown, 0, 'precond - did not teardown'); + assert.ok(!!save, 'precond - save hook'); + assert.deepEqual(atomArgs, {value:'initial-value', payload:{foo: "bar"}}, 'args initially empty'); + assert.hasElement(`#the-atom`, 'precond - displays atom'); + + let value = 'new-value'; + let payload = {foo: 'baz'}; + postDidChange = 0; + + save(value, payload); + + assert.equal(render, 2, 'rerenders atom'); + assert.equal(teardown, 1, 'tears down atom'); + assert.deepEqual(atomArgs, {value, payload}, 'updates atom values'); + assert.ok(postDidChange, 'post changed when saving atom'); + assert.hasElement(`#the-atom:contains(${value})`); +});