From 9a10d7ae182d09329125a5fd6aa4449a908a55e8 Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Mon, 13 Jul 2015 12:19:17 -0400 Subject: [PATCH] Test: displaying toolbar, clicking format butons, creating links * add `Helpers.dom.triggerKeyEvent` for simulating key events * add `Helpers.dom.triggerEvent` for simulating mouse events * Add View#destroy, destroy all views when destroying editor --- Brocfile.js | 4 +- broccoli/jquery.js | 13 ++- src/js/commands/card.js | 4 +- src/js/commands/format-block.js | 4 +- src/js/commands/image.js | 4 +- src/js/commands/oembed.js | 8 +- src/js/editor/editor.js | 32 +++++-- src/js/views/view.js | 4 + tests/acceptance/basic-editor-test.js | 4 +- tests/acceptance/editor-commands-test.js | 115 +++++++++++++++++++++++ tests/helpers/assertions.js | 15 +++ tests/helpers/dom.js | 106 +++++++++++++++++++++ tests/index.html | 9 ++ tests/test-helpers.js | 15 ++- tests/unit/editor/editor-destroy-test.js | 32 +++++++ tests/unit/editor/editor-test.js | 14 +-- 16 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 tests/acceptance/editor-commands-test.js create mode 100644 tests/helpers/assertions.js create mode 100644 tests/helpers/dom.js create mode 100644 tests/unit/editor/editor-destroy-test.js diff --git a/Brocfile.js b/Brocfile.js index 9b60bb4e6..42db94a37 100644 --- a/Brocfile.js +++ b/Brocfile.js @@ -18,10 +18,10 @@ var buildOptions = { packageName: packageName }; -var jqueryTree = jquery.build('/demo/jquery'); var testTree = testTreeBuilder.build({libDirName: 'src'}); +testTree = jquery.build(testTree, '/tests/jquery'); var demoTree = demo(); -demoTree = mergeTrees([demoTree, jqueryTree]); +demoTree = jquery.build(demoTree, '/demo/jquery'); module.exports = mergeTrees([ builder.build('amd', buildOptions), diff --git a/broccoli/jquery.js b/broccoli/jquery.js index 7127020a7..306325f97 100644 --- a/broccoli/jquery.js +++ b/broccoli/jquery.js @@ -1,17 +1,22 @@ /* jshint node:true */ var funnel = require('broccoli-funnel'); +var mergeTrees = require('broccoli-merge-trees'); module.exports = { - build: function(destDir) { + /** + * @param {Tree} tree existing tree to mix jquery into + * @param {String} destDir the destination directory for 'jquery.js' to go into + * @return {Tree} A tree with jquery mixed into it at the location requested + */ + build: function(tree, destDir) { var path = require('path'); var jqueryPath = path.dirname( require.resolve('jquery') ); - var tree = funnel(jqueryPath, { + var jqueryTree = funnel(jqueryPath, { include: ['jquery.js'], destDir: destDir }); - - return tree; + return mergeTrees([tree, jqueryTree]); } }; diff --git a/src/js/commands/card.js b/src/js/commands/card.js index 00422eed9..525f3d101 100644 --- a/src/js/commands/card.js +++ b/src/js/commands/card.js @@ -1,9 +1,10 @@ import Command from './base'; import { inherit } from 'content-kit-utils'; -function injectCardBlock(cardName, cardPayload, editor, index) { +function injectCardBlock(/* cardName, cardPayload, editor, index */) { throw new Error('Unimplemented: BlockModel and Type.CARD are no longer things'); // FIXME: Do we change the block model internal representation here? + /* var cardBlock = BlockModel.createWithType(Type.CARD, { attributes: { name: cardName, @@ -11,6 +12,7 @@ function injectCardBlock(cardName, cardPayload, editor, index) { } }); editor.replaceBlock(cardBlock, index); + */ } function CardCommand() { diff --git a/src/js/commands/format-block.js b/src/js/commands/format-block.js index 47fc11de2..01b2d38fe 100644 --- a/src/js/commands/format-block.js +++ b/src/js/commands/format-block.js @@ -17,7 +17,9 @@ FormatBlockCommand.prototype.exec = function() { // Allow block commands to be toggled back to a text block if(tag === blockElement.tagName.toLowerCase()) { throw new Error('Unimplemented: Type.BOLD.paragraph must be replaced'); + /* value = Type.PARAGRAPH.tag; + */ } else { // Flattens the selection before applying the block format. // Otherwise, undesirable nested blocks can occur. @@ -27,7 +29,7 @@ FormatBlockCommand.prototype.exec = function() { blockElement.parentNode.removeChild(blockElement); selectNode(flatNode); } - + FormatBlockCommand._super.prototype.exec.call(this, value); }; diff --git a/src/js/commands/image.js b/src/js/commands/image.js index 3b61fd432..846866be1 100644 --- a/src/js/commands/image.js +++ b/src/js/commands/image.js @@ -14,10 +14,12 @@ function createFileInput(command) { return fileInput; } -function injectImageBlock(src, editor, index) { +function injectImageBlock(/* src, editor, index */) { throw new Error('Unimplemented: BlockModel and Type.IMAGE are no longer things'); + /* var imageModel = BlockModel.createWithType(Type.IMAGE, { attributes: { src: src } }); editor.replaceBlock(imageModel, index); + */ } function renderFromFile(file, editor, index) { diff --git a/src/js/commands/oembed.js b/src/js/commands/oembed.js index 5d3b2da5f..1ec9b9d03 100644 --- a/src/js/commands/oembed.js +++ b/src/js/commands/oembed.js @@ -4,6 +4,7 @@ import Message from '../views/message'; import { inherit } from 'content-kit-utils'; import { OEmbedder } from '../utils/http-utils'; +/* function loadTwitterWidgets(element) { if (window.twttr) { window.twttr.widgets.load(element); @@ -14,6 +15,7 @@ function loadTwitterWidgets(element) { document.head.appendChild(script); } } +*/ function OEmbedCommand(options) { Command.call(this, { @@ -31,9 +33,9 @@ inherit(OEmbedCommand, Command); OEmbedCommand.prototype.exec = function(url) { var command = this; - var editorContext = command.editorContext; + // var editorContext = command.editorContext; var embedIntent = command.embedIntent; - var index = editorContext.getCurrentBlockIndex(); + // var index = editorContext.getCurrentBlockIndex(); embedIntent.showLoading(); this.embedService.fetch({ @@ -54,12 +56,14 @@ OEmbedCommand.prototype.exec = function(url) { embedIntent.show(); } else { throw new Error('Unimplemented EmbedModel is not a thing'); + /* var embedModel = new EmbedModel(response); editorContext.insertBlock(embedModel, index); editorContext.renderBlockAt(index); if (embedModel.attributes.provider_name.toLowerCase() === 'twitter') { loadTwitterWidgets(editorContext.element); } + */ } } }); diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index d9c66262e..4c788b388 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -124,14 +124,15 @@ function bindDragAndDrop(editor) { function initEmbedCommands(editor) { var commands = editor.embedCommands; if(commands) { - return new EmbedIntent({ + editor.addView(new EmbedIntent({ editorContext: editor, commands: commands, rootElement: editor.element - }); + })); } } +/* unused function getNonTextBlocks(blockTypeSet, post) { var blocks = []; var len = post.length; @@ -145,6 +146,7 @@ function getNonTextBlocks(blockTypeSet, post) { } return blocks; } +*/ function clearChildNodes(element) { while (element.childNodes.length) { @@ -165,12 +167,13 @@ function Editor(element, options) { } this._elementListeners = []; + this._views = []; this.element = element; // FIXME: This should merge onto this.options mergeWithOptions(this, defaults, options); - this._renderer = new EditorDOMRenderer(window.document, this.cards) + this._renderer = new EditorDOMRenderer(window.document, this.cards); this._parser = new DOMParser(); this.applyClassName(); @@ -196,16 +199,16 @@ function Editor(element, options) { this.addEventListener(element, 'input', () => this.handleInput(...arguments)); initEmbedCommands(this); - this.textFormatToolbar = new TextFormatToolbar({ + this.addView(new TextFormatToolbar({ rootElement: element, commands: this.textFormatCommands, sticky: this.stickyToolbar - }); + })); - this.linkTooltips = new Tooltip({ + this.addView(new Tooltip({ rootElement: element, showForTag: 'a' - }); + })); if (this.autofocus) { element.focus(); @@ -216,6 +219,9 @@ function Editor(element, options) { merge(Editor.prototype, EventEmitter); merge(Editor.prototype, { + addView(view) { + this._views.push(view); + }, addEventListener(context, eventName, callback) { context.addEventListener(eventName, callback); @@ -271,8 +277,9 @@ merge(Editor.prototype, { this.trigger('update'); }, - renderBlockAt(index, replace) { + renderBlockAt(/* index, replace */) { throw new Error('Unimplemented'); + /* var modelAtIndex = this.post[index]; var html = this.compiler.render([modelAtIndex]); var dom = document.createElement('div'); @@ -285,10 +292,12 @@ merge(Editor.prototype, { } else { this.element.insertBefore(newEl, sibling); } + */ }, syncContentEditableBlocks() { throw new Error('Unimplemented'); + /* var nonTextBlocks = getNonTextBlocks(this.compiler.blockTypes, this.post); var blockElements = toArray(this.element.children); var len = blockElements.length; @@ -309,6 +318,7 @@ merge(Editor.prototype, { } this.post = updatedModel; this.trigger('update'); + */ }, applyClassName() { @@ -426,8 +436,14 @@ merge(Editor.prototype, { }); }, + removeAllViews() { + this._views.forEach((v) => v.destroy()); + this._views = []; + }, + destroy() { this.removeAllEventListeners(); + this.removeAllViews(); } }); diff --git a/src/js/views/view.js b/src/js/views/view.js index 09970877e..d11537218 100644 --- a/src/js/views/view.js +++ b/src/js/views/view.js @@ -51,6 +51,10 @@ View.prototype = { setClasses: function(classNameArr) { this.classNames = classNameArr; renderClasses(this); + }, + destroy() { + // FIXME should also clean up event listeners + this.hide(); } }; diff --git a/tests/acceptance/basic-editor-test.js b/tests/acceptance/basic-editor-test.js index f66732273..8638cd04f 100644 --- a/tests/acceptance/basic-editor-test.js +++ b/tests/acceptance/basic-editor-test.js @@ -1,7 +1,7 @@ /* global QUnit */ import { Editor } from 'content-kit-editor'; -import { moveCursorTo } from '../test-helpers'; +import Helpers from '../test-helpers'; const { test, module } = QUnit; @@ -38,7 +38,7 @@ test('editing element changes editor post model', (assert) => { let p = editorElement.querySelector('p'); let textElement = p.firstChild; - moveCursorTo(textElement, 0); + Helpers.dom.moveCursorTo(textElement, 0); document.execCommand('insertText', false, 'A'); assert.equal(p.textContent, 'AHello'); diff --git a/tests/acceptance/editor-commands-test.js b/tests/acceptance/editor-commands-test.js new file mode 100644 index 000000000..2adceefc4 --- /dev/null +++ b/tests/acceptance/editor-commands-test.js @@ -0,0 +1,115 @@ +import { Editor } from 'content-kit-editor'; +import Helpers from '../test-helpers'; + +const { test, module } = QUnit; + +let fixture, editor, editorElement, selectedText; + +module('Acceptance: Editor commands', { + beforeEach() { + fixture = document.getElementById('qunit-fixture'); + editorElement = document.createElement('div'); + editorElement.setAttribute('id', 'editor'); + editorElement.innerHTML = 'THIS IS A TEST'; + fixture.appendChild(editorElement); + editor = new Editor(editorElement); + + selectedText = 'IS A'; + Helpers.dom.selectText(selectedText, editorElement); + Helpers.dom.triggerEvent(document, 'mouseup'); + }, + + afterEach() { + editor.destroy(); + } +}); + +function clickToolbarButton(name, assert) { + let btnSelector = `.ck-toolbar-btn[title="${name}"]`; + let button = assert.hasElement(btnSelector); + + Helpers.dom.triggerEvent(button[0], 'mouseup'); +} + +test('when text is highlighted, shows toolbar', (assert) => { + let done = assert.async(); + + setTimeout(() => { + assert.hasElement('.ck-toolbar', 'displays toolbar'); + assert.hasElement('.ck-toolbar-btn', 'displays toolbar buttons'); + let boldBtnSelector = '.ck-toolbar-btn[title="bold"]'; + assert.hasElement(boldBtnSelector, 'has bold button'); + done(); + }, 10); +}); + +test('highlight text, click "bold" button bolds text', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('bold', assert); + assert.hasElement('#editor b:contains(IS A)'); + + done(); + }, 10); +}); + +test('highlight text, click "italic" button italicizes text', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('italic', assert); + assert.hasElement('#editor i:contains(IS A)'); + + done(); + }, 10); +}); + +test('highlight text, click "heading" button turns text into h2 header', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('heading', assert); + assert.hasElement('#editor h2:contains(THIS IS A TEST)'); + + done(); + }, 10); +}); + +test('highlight text, click "subheading" button turns text into h3 header', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('subheading', assert); + assert.hasElement('#editor h3:contains(THIS IS A TEST)'); + + done(); + }, 10); +}); + +test('highlight text, click "quote" button turns text into blockquote', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('quote', assert); + assert.hasElement('#editor blockquote:contains(THIS IS A TEST)'); + + done(); + }, 10); +}); + +test('highlight text, click "link" button shows input for URL, makes link', (assert) => { + let done = assert.async(); + + setTimeout(() => { + clickToolbarButton('link', assert); + let input = assert.hasElement('.ck-toolbar-prompt input'); + let url = 'http://google.com'; + $(input).val(url); + Helpers.dom.triggerKeyEvent(input[0], 'keyup'); + + assert.hasElement(`#editor a[href="${url}"]:contains(${selectedText})`); + + done(); + }, 10); +}); diff --git a/tests/helpers/assertions.js b/tests/helpers/assertions.js new file mode 100644 index 000000000..c2c8a79b7 --- /dev/null +++ b/tests/helpers/assertions.js @@ -0,0 +1,15 @@ +/* global QUnit, $ */ + +export default function registerAssertions() { + QUnit.assert.hasElement = function(selector, message=`hasElement "${selector}"`) { + let found = $(selector); + this.push(found.length > 0, found.length, selector, message); + return found; + }; + + QUnit.assert.hasNoElement = function(selector, message=`hasNoElement "${selector}"`) { + let found = $(selector); + this.push(found.length === 0, found.length, selector, message); + return found; + }; +} diff --git a/tests/helpers/dom.js b/tests/helpers/dom.js new file mode 100644 index 000000000..6150a01f0 --- /dev/null +++ b/tests/helpers/dom.js @@ -0,0 +1,106 @@ +const TEXT_NODE = 3; + +function moveCursorTo(element, offset) { + let range = document.createRange(); + range.setStart(element, offset); + range.setEnd(element, offset); + + let selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + + +function walkDOMUntil(topNode, conditionFn=() => {}) { + let stack = [topNode]; + let currentElement; + + while (stack.length) { + currentElement = stack.pop(); + + if (conditionFn(currentElement)) { + return currentElement; + } + + for (let i=0; i < currentElement.childNodes.length; i++) { + stack.push(currentElement.childNodes[i]); + } + if (currentElement.nextSibling) { + stack.push(currentElement.nextSibling); + } + } +} + + +function selectText(text, containingElement) { + let textNode = walkDOMUntil(containingElement, (el) => { + if (el.nodeType !== TEXT_NODE) { return; } + + return el.textContent.indexOf(text) !== -1; + }); + if (!textNode) { + throw new Error(`Could not find a textNode containing "${text}"`); + } + let range = document.createRange(); + let startOffset = textNode.textContent.indexOf(text), + endOffset = startOffset + text.length; + range.setStart(textNode, startOffset); + range.setEnd(textNode, endOffset); + + let selection = window.getSelection(); + if (selection.rangeCount > 0) { selection.removeAllRanges(); } + selection.addRange(range); +} + +function triggerEvent(node, eventType) { + if (!node) { throw new Error(`Attempted to trigger event "${eventType}" on undefined node`); } + + let clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent(eventType, true, true); + node.dispatchEvent(clickEvent); +} + +// see https://gist.github.com/ejoubaud/7d7c57cda1c10a4fae8c +function createKeyEvent(eventType, key) { + var oEvent = document.createEvent('KeyboardEvent'); + + // Chromium Hack + Object.defineProperty(oEvent, 'keyCode', { + get : function() { + return this.keyCodeVal; + } + }); + Object.defineProperty(oEvent, 'which', { + get : function() { + return this.keyCodeVal; + } + }); + + if (oEvent.initKeyboardEvent) { + oEvent.initKeyboardEvent(eventType, true, true, document.defaultView, key, key, "", "", false, ""); + } else { + oEvent.initKeyEvent(eventType, true, true, document.defaultView, false, false, false, false, key, 0); + } + + oEvent.keyCodeVal = key; + + if (oEvent.keyCode !== key) { + throw new Error("keyCode mismatch " + oEvent.keyCode + "(" + oEvent.which + ")"); + } + + return oEvent; +} + +const ENTER_KEY_CODE = 13; +function triggerKeyEvent(node, eventType, keyCode=ENTER_KEY_CODE) { + let event = createKeyEvent('keyup', keyCode); + + node.dispatchEvent(event); +} + +export default { + moveCursorTo, + selectText, + triggerEvent, + triggerKeyEvent +}; diff --git a/tests/index.html b/tests/index.html index f3801d952..164b81c8a 100644 --- a/tests/index.html +++ b/tests/index.html @@ -4,6 +4,15 @@ Content-Kit-Editor tests + diff --git a/tests/test-helpers.js b/tests/test-helpers.js index 33df62a9a..4c72d16ff 100644 --- a/tests/test-helpers.js +++ b/tests/test-helpers.js @@ -1,9 +1,8 @@ -export function moveCursorTo(element, offset) { - let range = document.createRange(); - range.setStart(element, offset); - range.setEnd(element, offset); +import registerAssertions from './helpers/assertions'; +registerAssertions(); - let selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); -} +import DOMHelpers from './helpers/dom'; + +export default { + dom: DOMHelpers +}; diff --git a/tests/unit/editor/editor-destroy-test.js b/tests/unit/editor/editor-destroy-test.js new file mode 100644 index 000000000..903dd4afb --- /dev/null +++ b/tests/unit/editor/editor-destroy-test.js @@ -0,0 +1,32 @@ +const { module, test } = window.QUnit; +import Helpers from '../../test-helpers'; + +import { Editor } from 'content-kit-editor'; + +let editor; +let fixture; + +module('Unit: Editor #destroy', { + beforeEach() { + fixture = $('#qunit-fixture'); + fixture.html('the editor'); + editor = new Editor(fixture[0]); + }, + afterEach() { + } +}); + +test('removes toolbar from DOM', (assert) => { + let done = assert.async(); + + Helpers.dom.selectText('the editor', fixture[0]); + Helpers.dom.triggerEvent(document, 'mouseup'); + + setTimeout(() => { + assert.hasElement('.ck-toolbar', 'toolbar is shown'); + editor.destroy(); + assert.hasNoElement('.ck-toolbar', 'toolbar is removed'); + + done(); + }); +}); diff --git a/tests/unit/editor/editor-test.js b/tests/unit/editor/editor-test.js index f45b66bb0..86f05e7ca 100644 --- a/tests/unit/editor/editor-test.js +++ b/tests/unit/editor/editor-test.js @@ -1,5 +1,6 @@ -var fixture = document.getElementById('qunit-fixture'); -var editorElement = document.createElement('div'); +let fixture = document.getElementById('qunit-fixture'); +let editorElement = document.createElement('div'); +let editor; editorElement.id = 'editor1'; editorElement.className = 'editor'; @@ -8,21 +9,22 @@ import Editor from 'content-kit-editor/editor/editor'; const { module, test } = window.QUnit; module('Unit: Editor', { - setup: function() { + beforeEach: function() { fixture.appendChild(editorElement); }, - teardown: function() { + afterEach: function() { + editor.destroy(); fixture.removeChild(editorElement); } }); test('can create an editor via dom node reference', (assert) => { - var editor = new Editor(editorElement); + editor = new Editor(editorElement); assert.equal(editor.element, editorElement); }); test('can create an editor via dom node reference from getElementById', (assert) => { - var editor = new Editor(document.getElementById('editor1')); + editor = new Editor(document.getElementById('editor1')); assert.equal(editor.element, editorElement); });