From 2c5d2d6a2d8d159851ddbe827c9afa8a403c28c1 Mon Sep 17 00:00:00 2001 From: Austin Burdine Date: Mon, 10 Aug 2015 07:22:37 -0600 Subject: [PATCH] reimplement tag editing component for posts refs #3800 - remove old tag editor code - reimplement tag editor as an ember component - add tag editor component to PSM --- core/client/Brocfile.js | 1 + .../app/components/gh-post-tags-input.js | 149 ------- core/client/app/components/gh-tags-input.js | 280 ++++++++++++ .../client/app/controllers/post-tags-input.js | 248 ----------- core/client/app/styles/layouts/editor.css | 45 +- core/client/app/styles/patterns/forms.css | 1 + .../components/gh-post-tags-input.hbs | 23 - .../templates/components/gh-tags-input.hbs | 6 + .../app/templates/post-settings-menu.hbs | 5 + core/client/bower.json | 1 + .../components/gh-post-tags-input-test.js | 27 -- .../unit/components/gh-tags-input-test.js | 405 ++++++++++++++++++ core/test/functional/base.js | 3 - core/test/functional/client/psm_test.js | 160 +++++-- 14 files changed, 868 insertions(+), 486 deletions(-) delete mode 100644 core/client/app/components/gh-post-tags-input.js create mode 100644 core/client/app/components/gh-tags-input.js delete mode 100644 core/client/app/controllers/post-tags-input.js delete mode 100644 core/client/app/templates/components/gh-post-tags-input.hbs create mode 100644 core/client/app/templates/components/gh-tags-input.hbs delete mode 100644 core/client/tests/unit/components/gh-post-tags-input-test.js create mode 100644 core/client/tests/unit/components/gh-tags-input-test.js diff --git a/core/client/Brocfile.js b/core/client/Brocfile.js index eedda154a0..e3045cd20e 100644 --- a/core/client/Brocfile.js +++ b/core/client/Brocfile.js @@ -59,6 +59,7 @@ app.import('bower_components/codemirror/mode/javascript/javascript.js'); app.import('bower_components/xregexp/xregexp-all.js'); app.import('bower_components/password-generator/lib/password-generator.js'); app.import('bower_components/blueimp-md5/js/md5.js'); +app.import('bower_components/typeahead.js/dist/typeahead.bundle.js'); // 'dem Styles app.import('bower_components/codemirror/lib/codemirror.css'); diff --git a/core/client/app/components/gh-post-tags-input.js b/core/client/app/components/gh-post-tags-input.js deleted file mode 100644 index 2ce07d1073..0000000000 --- a/core/client/app/components/gh-post-tags-input.js +++ /dev/null @@ -1,149 +0,0 @@ -import Ember from 'ember'; - -export default Ember.Component.extend({ - tagName: 'section', - elementId: 'entry-tags', - classNames: 'publish-bar-inner', - classNameBindings: ['hasFocus:focused'], - - hasFocus: false, - - keys: { - BACKSPACE: 8, - TAB: 9, - ENTER: 13, - ESCAPE: 27, - UP: 38, - DOWN: 40, - NUMPAD_ENTER: 108 - }, - - didInsertElement: function () { - // this.get('controller').send('loadAllTags'); - }, - - willDestroyElement: function () { - // this.get('controller').send('reset'); - }, - - overlayStyles: Ember.computed('hasFocus', 'controller.suggestions.length', function () { - var styles = [], - leftPos; - - if (this.get('hasFocus') && this.get('controller.suggestions.length')) { - leftPos = this.$().find('#tags').position().left; - styles.push('display: block'); - styles.push('left: ' + leftPos + 'px'); - } else { - styles.push('display: none'); - styles.push('left', 0); - } - - return styles.join(';').htmlSafe(); - }), - - // replace these views with components, or whatever works - // during the reimplementation of this component. - - // tagInputView: Ember.TextField.extend({ - // focusIn: function () { - // this.get('parentView').set('hasFocus', true); - // }, - - // focusOut: function () { - // this.get('parentView').set('hasFocus', false); - // }, - - // keyPress: function (event) { - // // listen to keypress event to handle comma key on international keyboard - // var controller = this.get('parentView.controller'), - // isComma = ','.localeCompare(String.fromCharCode(event.keyCode || event.charCode)) === 0; - - // // use localeCompare in case of international keyboard layout - // if (isComma) { - // event.preventDefault(); - - // if (controller.get('selectedSuggestion')) { - // controller.send('addSelectedSuggestion'); - // } else { - // controller.send('addNewTag'); - // } - // } - // }, - - // keyDown: function (event) { - // var controller = this.get('parentView.controller'), - // keys = this.get('parentView.keys'), - // hasValue; - - // switch (event.keyCode) { - // case keys.UP: - // event.preventDefault(); - // controller.send('selectPreviousSuggestion'); - // break; - - // case keys.DOWN: - // event.preventDefault(); - // controller.send('selectNextSuggestion'); - // break; - - // case keys.TAB: - // case keys.ENTER: - // case keys.NUMPAD_ENTER: - // if (controller.get('selectedSuggestion')) { - // event.preventDefault(); - // controller.send('addSelectedSuggestion'); - // } else { - // // allow user to tab out of field if input is empty - // hasValue = !Ember.isEmpty(this.get('value')); - // if (hasValue || event.keyCode !== keys.TAB) { - // event.preventDefault(); - // controller.send('addNewTag'); - // } - // } - // break; - - // case keys.BACKSPACE: - // if (Ember.isEmpty(this.get('value'))) { - // event.preventDefault(); - // controller.send('deleteLastTag'); - // } - // break; - - // case keys.ESCAPE: - // event.preventDefault(); - // controller.send('reset'); - // break; - // } - // } - // }), - - // suggestionView: Ember.View.extend({ - // tagName: 'li', - // classNameBindings: 'suggestion.selected', - - // suggestion: null, - - // // we can't use the 'click' event here as the focusOut event on the - // // input will fire first - - // mouseDown: function (event) { - // event.preventDefault(); - // }, - - // mouseUp: function (event) { - // event.preventDefault(); - // this.get('parentView.controller').send('addTag', - // this.get('suggestion.tag')); - // } - // }), - - actions: { - deleteTag: function (tag) { - // The view wants to keep focus on the input after a click on a tag - Ember.$('.js-tag-input').focus(); - // Make the controller do the actual work - this.sendAction('deleteTag', tag); - } - } -}); diff --git a/core/client/app/components/gh-tags-input.js b/core/client/app/components/gh-tags-input.js new file mode 100644 index 0000000000..7e32022ee4 --- /dev/null +++ b/core/client/app/components/gh-tags-input.js @@ -0,0 +1,280 @@ +/* global Bloodhound, key */ +import Ember from 'ember'; + +/** + * Ghost Tag Input Component + * + * Creates an input field that is used to input tags for a post. + * @param {Boolean} hasFocus Whether or not the input is focused + * @param {DS.Model} post The current post object to input tags for + */ +export default Ember.Component.extend({ + classNames: ['gh-input'], + classNameBindings: ['hasFocus:focus'], + + // Uses the Ember-Data store directly, as it needs to create and get tag records + store: Ember.inject.service(), + + hasFocus: false, + post: null, + highlightIndex: null, + + isDirty: false, + isReloading: false, + + unassignedTags: Ember.A(), // tags that AREN'T assigned to this post + currentTags: Ember.A(), // tags that ARE assigned to this post + + // Input field events + click: function () { + this.$('#tag-input').focus(); + }, + + focusIn: function () { + this.set('hasFocus', true); + key.setScope('tags'); + }, + + focusOut: function () { + this.set('hasFocus', false); + key.setScope('default'); + this.set('highlightIndex', null); + // if there is text in the input field, create a tag with it + if (this.$('#tag-input').val() !== '') { + this.send('addTag', this.$('#tag-input').val()); + } + this.saveTags(); + }, + + keyPress: function (event) { + var val = this.$('#tag-input').val(), + isComma = ','.localeCompare(String.fromCharCode(event.keyCode || event.charCode)) === 0; + + if (isComma && val !== '') { + event.preventDefault(); + this.send('addTag', val); + } + }, + + // Tag Loading functions + loadTagsOnInit: Ember.on('init', function () { + var self = this; + + if (this.get('post')) { + this.loadTags().then(function () { + Ember.run.schedule('afterRender', self, 'initTypeahead'); + }); + } + }), + + reloadTags: Ember.observer('post', function () { + var self = this; + + this.loadTags().then(function () { + self.reloadTypeahead(false); + }); + }), + + loadTags: function () { + var self = this, + post = this.get('post'); + + this.get('currentTags').clear(); + this.get('unassignedTags').clear(); + + return this.get('store').find('tag', {limit: 'all'}).then(function (tags) { + if (post.get('id')) { // if it's a new post, it won't have an id + self.get('currentTags').pushObjects(post.get('tags').toArray()); + } + + tags.forEach(function (tag) { + if (Ember.isEmpty(post.get('id')) || Ember.isEmpty(self.get('currentTags').findBy('id', tag.get('id')))) { + self.get('unassignedTags').pushObject(tag); + } + }); + + return Ember.RSVP.resolve(); + }); + }, + + // Key Binding functions + bindKeys: function () { + var self = this; + + key('enter, tab', 'tags', function (event) { + var val = self.$('#tag-input').val(); + + if (val !== '') { + event.preventDefault(); + self.send('addTag', val); + } + }); + + key('backspace', 'tags', function (event) { + if (self.$('#tag-input').val() === '') { + event.preventDefault(); + self.send('deleteTag'); + } + }); + + key('left', 'tags', function (event) { + self.updateHighlightIndex(-1, event); + }); + + key('right', 'tags', function (event) { + self.updateHighlightIndex(1, event); + }); + }, + + unbindKeys: function () { + key.unbind('enter, tab', 'tags'); + key.unbind('backspace', 'tags'); + key.unbind('left', 'tags'); + key.unbind('right', 'tags'); + }, + + didInsertElement: function () { + this.bindKeys(); + }, + + willDestroyElement: function () { + this.unbindKeys(); + this.destroyTypeahead(); + }, + + updateHighlightIndex: function (modifier, event) { + if (this.$('#tag-input').val() === '') { + var highlightIndex = this.get('highlightIndex'), + length = this.get('currentTags.length'), + newIndex; + + if (event) { + event.preventDefault(); + } + + if (highlightIndex === null) { + newIndex = (modifier > 0) ? 0 : length - 1; + } else { + newIndex = highlightIndex + modifier; + if (newIndex < 0 || newIndex >= length) { + newIndex = null; + } + } + this.set('highlightIndex', newIndex); + } + }, + + // Typeahead functions + initTypeahead: function () { + var tags = new Bloodhound({ + datumTokenizer: Bloodhound.tokenizers.whitespace, + queryTokenizer: Bloodhound.tokenizers.whitespace, + local: this.get('unassignedTags').map(function (tag) { + return tag.get('name'); + }) + }); + + this.$('#tag-input').typeahead({ + minLength: 1, + classNames: { + // TODO: Fix CSS for these + input: 'tag-input', + hint: 'tag-input', + menu: 'dropdown-menu', + suggestion: 'dropdown-item', + open: 'open' + } + }, { + name: 'tags', + source: tags + }).bind('typeahead:selected', Ember.run.bind(this, 'typeaheadAdd')); + }, + + destroyTypeahead: function () { + this.$('#tag-input').typeahead('destroy'); + }, + + reloadTypeahead: function (refocus) { + this.set('isReloading', true); + this.destroyTypeahead(); + this.initTypeahead(); + if (refocus) { + this.click(); + } + this.set('isReloading', false); + }, + + // Tag Saving / Tag Add/Delete Actions + saveTags: function () { + var post = this.get('post'); + + if (post && this.get('isDirty') && !this.get('isReloading')) { + post.get('tags').clear(); + post.get('tags').pushObjects(this.get('currentTags').toArray()); + this.set('isDirty', false); + } + }, + + // Used for typeahead selection + typeaheadAdd: function (obj, datum) { + if (datum) { + // this is needed so two tags with the same name aren't added + this.$('#tag-input').typeahead('val', ''); + this.send('addTag', datum); + } + }, + + actions: { + addTag: function (tagName) { + var tagToAdd, checkTag; + + // Prevent multiple tags with the same name occuring + if (this.get('currentTags').findBy('name', tagName)) { + this.$('#tag-input').typeahead('val', ''); + return; + } + + checkTag = this.get('unassignedTags').findBy('name', tagName); + + if (checkTag) { + tagToAdd = checkTag; + this.get('unassignedTags').removeObject(checkTag); + this.reloadTypeahead(); + } else { + tagToAdd = this.get('store').createRecord('tag', {name: tagName}); + } + + this.set('isDirty', true); + this.set('highlightIndex', null); + this.get('currentTags').pushObject(tagToAdd); + this.$('#tag-input').typeahead('val', ''); + }, + + deleteTag: function (tag) { + var removedTag; + + if (tag) { + removedTag = this.get('currentTags').findBy('name', tag); + this.get('currentTags').removeObject(removedTag); + } else { + if (this.get('highlightIndex') !== null) { + removedTag = this.get('currentTags').objectAt(this.get('highlightIndex')); + this.get('currentTags').removeObject(removedTag); + this.set('highlightIndex', null); + } else { + this.set('highlightIndex', this.get('currentTags.length') - 1); + } + } + + if (removedTag) { + if (removedTag.get('isNew')) { // if tag is new, don't change isDirty, + removedTag.deleteRecord(); // and delete the new record + } else { + this.set('isDirty', true); + this.get('unassignedTags').pushObject(removedTag); + this.reloadTypeahead(); + } + } + } + } +}); diff --git a/core/client/app/controllers/post-tags-input.js b/core/client/app/controllers/post-tags-input.js deleted file mode 100644 index 548f0ca1dd..0000000000 --- a/core/client/app/controllers/post-tags-input.js +++ /dev/null @@ -1,248 +0,0 @@ -import Ember from 'ember'; - -// should be integrated into tag input component during reimplementation - -export default Ember.Controller.extend({ - tagEnteredOrder: Ember.A(), - - tags: Ember.computed('parentController.model.tags', function () { - var proxyTags = Ember.ArrayProxy.create({ - content: this.get('parentController.model.tags') - }), - temp = proxyTags.get('arrangedContent').slice(); - - proxyTags.get('arrangedContent').clear(); - - this.get('tagEnteredOrder').forEach(function (tagName) { - var tag = temp.find(function (tag) { - return tag.get('name') === tagName; - }); - - if (tag) { - proxyTags.get('arrangedContent').addObject(tag); - temp.removeObject(tag); - } - }); - - proxyTags.get('arrangedContent').unshiftObjects(temp); - - return proxyTags; - }), - - suggestions: null, - newTagText: null, - - actions: { - // triggered when the view is inserted so that later store.all('tag') - // queries hit a full store cache and we don't see empty or out-of-date - // suggestion lists - loadAllTags: function () { - this.store.find('tag', {limit: 'all'}); - }, - - addNewTag: function () { - var newTagText = this.get('newTagText'), - searchTerm, - existingTags, - newTag; - - if (Ember.isEmpty(newTagText) || this.hasTag(newTagText)) { - this.send('reset'); - return; - } - - newTagText = newTagText.trim(); - searchTerm = newTagText.toLowerCase(); - - // add existing tag if we have a match - existingTags = this.store.all('tag').filter(function (tag) { - if (tag.get('isNew')) { - return false; - } - - return tag.get('name').toLowerCase() === searchTerm; - }); - - if (existingTags.get('length')) { - this.send('addTag', existingTags.get('firstObject')); - } else { - // otherwise create a new one - newTag = this.store.createRecord('tag'); - newTag.set('name', newTagText); - - this.send('addTag', newTag); - } - - this.send('reset'); - }, - - addTag: function (tag) { - if (!Ember.isEmpty(tag)) { - this.get('tags').addObject(tag); - this.get('tagEnteredOrder').addObject(tag.get('name')); - } - - this.send('reset'); - }, - - deleteTag: function (tag) { - if (tag) { - this.get('tags').removeObject(tag); - this.get('tagEnteredOrder').removeObject(tag.get('name')); - } - }, - - deleteLastTag: function () { - this.send('deleteTag', this.get('tags.lastObject')); - }, - - selectSuggestion: function (suggestion) { - if (!Ember.isEmpty(suggestion)) { - this.get('suggestions').setEach('selected', false); - suggestion.set('selected', true); - } - }, - - selectNextSuggestion: function () { - var suggestions = this.get('suggestions'), - selectedSuggestion = this.get('selectedSuggestion'), - currentIndex, - newSelection; - - if (!Ember.isEmpty(suggestions)) { - currentIndex = suggestions.indexOf(selectedSuggestion); - if (currentIndex + 1 < suggestions.get('length')) { - newSelection = suggestions[currentIndex + 1]; - this.send('selectSuggestion', newSelection); - } else { - suggestions.setEach('selected', false); - } - } - }, - - selectPreviousSuggestion: function () { - var suggestions = this.get('suggestions'), - selectedSuggestion = this.get('selectedSuggestion'), - currentIndex, - lastIndex, - newSelection; - - if (!Ember.isEmpty(suggestions)) { - currentIndex = suggestions.indexOf(selectedSuggestion); - if (currentIndex === -1) { - lastIndex = suggestions.get('length') - 1; - this.send('selectSuggestion', suggestions[lastIndex]); - } else if (currentIndex - 1 >= 0) { - newSelection = suggestions[currentIndex - 1]; - this.send('selectSuggestion', newSelection); - } else { - suggestions.setEach('selected', false); - } - } - }, - - addSelectedSuggestion: function () { - var suggestion = this.get('selectedSuggestion'); - - if (Ember.isEmpty(suggestion)) { - return; - } - - this.send('addTag', suggestion.get('tag')); - }, - - reset: function () { - this.set('suggestions', null); - this.set('newTagText', null); - } - }, - - selectedSuggestion: Ember.computed('suggestions.@each.selected', function () { - var suggestions = this.get('suggestions'); - - if (suggestions && suggestions.get('length')) { - return suggestions.filterBy('selected').get('firstObject'); - } else { - return null; - } - }), - - updateSuggestionsList: Ember.observer('newTagText', function () { - var searchTerm = this.get('newTagText'), - matchingTags, - // Limit the suggestions number - maxSuggestions = 5, - suggestions = Ember.A(); - - if (!searchTerm || Ember.isEmpty(searchTerm.trim())) { - this.set('suggestions', null); - return; - } - - searchTerm = searchTerm.trim(); - - matchingTags = this.findMatchingTags(searchTerm); - matchingTags = matchingTags.slice(0, maxSuggestions); - matchingTags.forEach(function (matchingTag) { - var suggestion = this.makeSuggestionObject(matchingTag, searchTerm); - suggestions.pushObject(suggestion); - }, this); - - this.set('suggestions', suggestions); - }), - - findMatchingTags: function (searchTerm) { - var matchingTags, - self = this, - allTags = this.store.all('tag').filterBy('isNew', false), - deDupe = {}; - - if (allTags.get('length') === 0) { - return []; - } - - searchTerm = searchTerm.toLowerCase(); - - matchingTags = allTags.filter(function (tag) { - var tagNameMatches, - hasAlreadyBeenAdded, - tagName = tag.get('name'); - - tagNameMatches = tagName.toLowerCase().indexOf(searchTerm) !== -1; - hasAlreadyBeenAdded = self.hasTag(tagName); - - if (tagNameMatches && !hasAlreadyBeenAdded) { - if (typeof deDupe[tagName] === 'undefined') { - deDupe[tagName] = 1; - } else { - deDupe[tagName] += 1; - } - } - - return deDupe[tagName] === 1; - }); - - return matchingTags; - }, - - hasTag: function (tagName) { - return this.get('tags').mapBy('name').contains(tagName); - }, - - makeSuggestionObject: function (matchingTag, _searchTerm) { - var searchTerm = Ember.Handlebars.Utils.escapeExpression(_searchTerm), - regexEscapedSearchTerm = searchTerm.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), - tagName = Ember.Handlebars.Utils.escapeExpression(matchingTag.get('name')), - regex = new RegExp('(' + regexEscapedSearchTerm + ')', 'gi'), - highlightedName, - suggestion = Ember.Object.create(); - - highlightedName = tagName.replace(regex, '$1'); - highlightedName = Ember.String.htmlSafe(highlightedName); - - suggestion.set('tag', matchingTag); - suggestion.set('highlightedName', highlightedName); - - return suggestion; - } -}); diff --git a/core/client/app/styles/layouts/editor.css b/core/client/app/styles/layouts/editor.css index 3f25e3c5bb..4c82eaf189 100644 --- a/core/client/app/styles/layouts/editor.css +++ b/core/client/app/styles/layouts/editor.css @@ -249,7 +249,48 @@ display: none; } -#entry-tags input[type="text"].tag-input { +/* Tags input CSS (TODO: needs some revision) +/* ------------------------------------------------------ */ +.tags-input-list { + display: flex; + flex-wrap: wrap; + margin: 0; + padding: 0; + list-style-type: none; +} + +.tags-input-list li { + flex: 1 0 auto; +} + +.label-tag { + margin-right: 0.3em; + padding: 0.2em 0.6em 0.3em; + background-color: var(--darkgrey); + border-radius: 0.25em; + color: var(--lightgrey); + text-align: center; + font-weight: 300; +} + +.label-tag.highlight { + background: var(--midgrey); + color: #fff; +} + +.tag-input { + margin-top: 5px; + border: none; + font-weight: 300; + cursor: default; +} + +.tag-input:focus { + outline: 0; +} + +/* TODO: can be removed once tag-component css is fixed */ +/*#entry-tags input[type="text"].tag-input { display: inline-block; padding: 9px 9px 9px 0; width: 100%; @@ -410,7 +451,7 @@ position: relative; flex: 1 1 auto; align-self: auto; -} +} */ .publish-bar-actions { flex: 1 0 auto; diff --git a/core/client/app/styles/patterns/forms.css b/core/client/app/styles/patterns/forms.css index 969454068f..568ee18341 100644 --- a/core/client/app/styles/patterns/forms.css +++ b/core/client/app/styles/patterns/forms.css @@ -125,6 +125,7 @@ select.error { } .gh-input:focus, +.gh-input.focus, .gh-select:focus, select:focus { outline: 0; diff --git a/core/client/app/templates/components/gh-post-tags-input.hbs b/core/client/app/templates/components/gh-post-tags-input.hbs deleted file mode 100644 index fdf3b46c3a..0000000000 --- a/core/client/app/templates/components/gh-post-tags-input.hbs +++ /dev/null @@ -1,23 +0,0 @@ -
- -
-
-
- {{#each tags as |tag|}} - {{tag.name}} - {{/each}} -
-
-
- - {{!-- {{view view.tagInputView class="tag-input js-tag-input" id="tags" value=newTagText}} - --}} -
diff --git a/core/client/app/templates/components/gh-tags-input.hbs b/core/client/app/templates/components/gh-tags-input.hbs new file mode 100644 index 0000000000..678214802d --- /dev/null +++ b/core/client/app/templates/components/gh-tags-input.hbs @@ -0,0 +1,6 @@ + diff --git a/core/client/app/templates/post-settings-menu.hbs b/core/client/app/templates/post-settings-menu.hbs index 2eea3dbc17..2f9d9dd5ca 100644 --- a/core/client/app/templates/post-settings-menu.hbs +++ b/core/client/app/templates/post-settings-menu.hbs @@ -33,6 +33,11 @@ +
+ + {{gh-tags-input post=model}} +
+ {{#unless session.user.isAuthor}}
diff --git a/core/client/bower.json b/core/client/bower.json index 415b1a5679..323d21cf72 100644 --- a/core/client/bower.json +++ b/core/client/bower.json @@ -27,6 +27,7 @@ "rangyinputs": "1.2.0", "showdown-ghost": "0.3.6", "sinonjs": "1.14.1", + "typeahead.js": "0.11.1", "validator-js": "3.39.0", "xregexp": "2.0.0" } diff --git a/core/client/tests/unit/components/gh-post-tags-input-test.js b/core/client/tests/unit/components/gh-post-tags-input-test.js deleted file mode 100644 index 12410498a2..0000000000 --- a/core/client/tests/unit/components/gh-post-tags-input-test.js +++ /dev/null @@ -1,27 +0,0 @@ -/* jshint expr:true */ -import {expect} from 'chai'; -import { - describeComponent, - it -} from 'ember-mocha'; - -describeComponent( - 'gh-post-tags-input', - 'GhPostTagsInputComponent', - { - // specify the other units that are required for this test - // needs: ['component:foo', 'helper:bar'] - }, - function () { - it('renders', function () { - // creates the component instance - var component = this.subject(); - - expect(component._state).to.equal('preRender'); - - // renders the component on the page - this.render(); - expect(component._state).to.equal('inDOM'); - }); - } -); diff --git a/core/client/tests/unit/components/gh-tags-input-test.js b/core/client/tests/unit/components/gh-tags-input-test.js new file mode 100644 index 0000000000..bb653d6e0c --- /dev/null +++ b/core/client/tests/unit/components/gh-tags-input-test.js @@ -0,0 +1,405 @@ +/* jshint expr:true */ +import Ember from 'ember'; +import { expect } from 'chai'; +import { + describeComponent, + it +} from 'ember-mocha'; + +describeComponent( + 'gh-tags-input', + 'GhTagsInputComponent', + { + needs: ['helper:is-equal'] + }, + function () { + var post = Ember.Object.create({ + id: 1, + tags: Ember.A() + }); + + beforeEach(function () { + var store = Ember.Object.create({ + tags: Ember.A(), + + find: function () { + return Ember.RSVP.resolve(this.get('tags')); + }, + + createRecord: function (name, opts) { + return Ember.Object.create({ + isNew: true, + isDeleted: false, + + name: opts.name, + + deleteRecord: function () { + this.set('isDeleted', true); + } + }); + } + }); + + store.get('tags').pushObject(Ember.Object.create({ + id: 1, + name: 'Test1' + })); + store.get('tags').pushObject(Ember.Object.create({ + id: 2, + name: 'Test2' + })); + + this.subject().set('store', store); + }); + + afterEach(function () { + post.get('tags').clear(); // reset tags + }); + + it('renders with null post', function () { + // creates the component instance + var component = this.subject(); + expect(component._state).to.equal('preRender'); + + // renders the component on the page + this.render(); + expect(component._state).to.equal('inDOM'); + }); + + it('correctly loads all tags', function () { + var component = this.subject(); + + this.render(); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(0); + }); + + it('correctly loads & filters tags when post has tags', function () { + var component = this.subject(); + + post.get('tags').pushObject(Ember.Object.create({ + id: 1, + name: 'Test1' + })); + + this.render(); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(1); + expect(component.get('currentTags.length')).to.equal(1); + expect(component.get('unassignedTags').findBy('id', 1)).to.not.exist; + expect(component.get('unassignedTags').findBy('id', 2)).to.exist; + }); + + it('correctly adds new tag to currentTags', function () { + var component = this.subject(); + + this.render(); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(0); + + Ember.run(function () { + component.send('addTag', 'Test3'); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(1); + expect(component.get('isDirty')).to.be.true; + expect(component.get('currentTags').findBy('name', 'Test3')).to.exist; + }); + + it('correctly adds existing tag to currentTags', function () { + var component = this.subject(); + + this.render(); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(0); + + Ember.run(function () { + component.send('addTag', 'Test2'); + }); + + expect(component.get('unassignedTags.length')).to.equal(1); + expect(component.get('currentTags.length')).to.equal(1); + expect(component.get('isDirty')).to.be.true; + expect(component.get('currentTags').findBy('name', 'Test2')).to.exist; + expect(component.get('unassignedTags').findBy('name', 'Test2')).to.not.exist; + }); + + it('doesn\'t allow duplicate tags to be added', function () { + var component = this.subject(); + + this.render(); + + post.get('tags').pushObject(Ember.Object.create({ + id: 1, + name: 'Test1' + })); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(1); + expect(component.get('currentTags.length')).to.equal(1); + + Ember.run(function () { + component.send('addTag', 'Test1'); + }); + + expect(component.get('unassignedTags.length')).to.equal(1); + expect(component.get('currentTags.length')).to.equal(1); + }); + + it('deletes new tag correctly', function () { + var component = this.subject(); + + this.render(); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(0); + + Ember.run(function () { + component.send('addTag', 'Test3'); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(1); + + Ember.run(function () { + component.send('deleteTag', 'Test3'); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(0); + expect(component.get('currentTags').findBy('name', 'Test3')).to.not.exist; + expect(component.get('unassignedTags').findBy('name', 'Test3')).to.not.exist; + }); + + it('deletes existing tag correctly', function () { + var component = this.subject(); + + this.render(); + + post.get('tags').pushObject(Ember.Object.create({ + id: 1, + name: 'Test1' + })); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(1); + expect(component.get('currentTags.length')).to.equal(1); + expect(component.get('unassignedTags').findBy('name', 'Test1')).to.not.exist; + + Ember.run(function () { + component.send('deleteTag', 'Test1'); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(0); + expect(component.get('unassignedTags').findBy('name', 'Test1')).to.exist; + }); + + it('creates tag with leftover text when component is de-focused', function () { + var component = this.subject(); + + this.render(); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(0); + + component.$('#tag-input').typeahead('val', 'Test3'); + component.focusOut(); // simluate de-focus + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(1); + }); + + it('sets highlight index to length-1 if it is null and modifier is negative', function () { + var component = this.subject(); + + this.render(); + + post.get('tags').pushObject(Ember.Object.create({ + id: 3, + name: 'Test3' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 4, + name: 'Test4' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 5, + name: 'Test5' + })); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(3); + + Ember.run(function () { + component.updateHighlightIndex(-1); + }); + + expect(component.get('highlightIndex')).to.equal(2); + }); + + it('sets highlight index to 0 if it is null and modifier is positive', function () { + var component = this.subject(); + + this.render(); + + post.get('tags').pushObject(Ember.Object.create({ + id: 3, + name: 'Test3' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 4, + name: 'Test4' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 5, + name: 'Test5' + })); + + Ember.run(function () { + component.set('post', post); + }); + + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(3); + + Ember.run(function () { + component.updateHighlightIndex(1); + }); + + expect(component.get('highlightIndex')).to.equal(0); + }); + + it('increments highlight index correctly (no reset)', function () { + var component = this.subject(); + + this.render(); + + post.get('tags').pushObject(Ember.Object.create({ + id: 3, + name: 'Test3' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 4, + name: 'Test4' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 5, + name: 'Test5' + })); + + Ember.run(function () { + component.set('post', post); + component.set('highlightIndex', 1); + }); + + expect(component.get('highlightIndex')).to.equal(1); + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(3); + + Ember.run(function () { + component.updateHighlightIndex(1); + }); + + expect(component.get('highlightIndex')).to.equal(2); + + Ember.run(function () { + component.updateHighlightIndex(-1); + }); + + expect(component.get('highlightIndex')).to.equal(1); + }); + + it('increments highlight index correctly (with reset)', function () { + var component = this.subject(); + + this.render(); + + post.get('tags').pushObject(Ember.Object.create({ + id: 3, + name: 'Test3' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 4, + name: 'Test4' + })); + + post.get('tags').pushObject(Ember.Object.create({ + id: 5, + name: 'Test5' + })); + + Ember.run(function () { + component.set('post', post); + component.set('highlightIndex', 2); + }); + + expect(component.get('highlightIndex')).to.equal(2); + expect(component.get('unassignedTags.length')).to.equal(2); + expect(component.get('currentTags.length')).to.equal(3); + + Ember.run(function () { + component.updateHighlightIndex(1); + }); + + expect(component.get('highlightIndex')).to.be.null; + + Ember.run(function () { + component.set('highlightIndex', 0); + }); + + expect(component.get('highlightIndex')).to.equal(0); + + Ember.run(function () { + component.updateHighlightIndex(-1); + }); + + expect(component.get('highlightIndex')).to.be.null; + }); + } +); diff --git a/core/test/functional/base.js b/core/test/functional/base.js index 8dd1e53f86..ed63ba9a27 100644 --- a/core/test/functional/base.js +++ b/core/test/functional/base.js @@ -469,9 +469,6 @@ CasperTest.Routines = (function () { casper.thenOpenAndWaitForPageLoad('editor', function createTestPost() { casper.sendKeys('#entry-title', testPost.title); casper.writeContentToEditor(testPost.html); - // TODO move these into psm tests when tags have been added there - // casper.sendKeys('#entry-tags input.tag-input', 'TestTag'); - // casper.sendKeys('#entry-tags input.tag-input', casper.page.event.key.Enter); }); casper.waitForSelectorTextChange('.entry-preview .rendered-markdown'); diff --git a/core/test/functional/client/psm_test.js b/core/test/functional/client/psm_test.js index b91f5db62a..3607bb43b7 100644 --- a/core/test/functional/client/psm_test.js +++ b/core/test/functional/client/psm_test.js @@ -180,37 +180,129 @@ CasperTest.begin('Post url input is reset from all whitespace back to original v test.assertEquals(slugVal, originalSlug, 'slug gets reset to original value'); }); }); -// TODO this test is from editor_test and needs to come back in some form when tags are moved into PSM -// CasperTest.begin('Tag editor', 7, function suite(test) { -// casper.thenOpenAndWaitForPageLoad('editor', function testTitleAndUrl() { -// test.assertTitle('Editor - Test Blog', 'Ghost admin has incorrect title'); -// test.assertUrlMatch(/ghost\/editor\/$/, 'Landed on the correct URL'); -// }); -// -// var tagName = 'someTagName', -// createdTagSelector = '#entry-tags .tags .tag'; -// -// casper.then(function () { -// test.assertExists('#entry-tags', 'should have tag label area'); -// test.assertExists('#entry-tags .tag-label', 'should have tag label icon'); -// test.assertExists('#entry-tags input.tag-input', 'should have tag input area'); -// }); -// -// casper.thenClick('#entry-tags input.tag-input'); -// casper.then(function () { -// casper.sendKeys('#entry-tags input.tag-input', tagName, {keepFocus: true}); -// }); -// casper.then(function () { -// casper.sendKeys('#entry-tags input.tag-input', casper.page.event.key.Enter); -// }); -// -// casper.waitForSelector(createdTagSelector, function onSuccess() { -// test.assertSelectorHasText(createdTagSelector, tagName, 'typing enter after tag name should create tag'); -// }); -// -// casper.thenClick(createdTagSelector); -// -// casper.waitWhileSelector(createdTagSelector, function onSuccess() { -// test.assert(true, 'clicking the tag should delete the tag'); -// }); -// }); + +CasperTest.begin('Tag Editor', 18, function suite(test) { + var testTag = 'Test1', + createdTag = '.tags-input-list li.label-tag'; + + casper.thenOpenAndWaitForPageLoad('editor', function testTitleAndUrl() { + test.assertTitle('Editor - Test Blog', 'Ghost admin has incorrect title'); + test.assertUrlMatch(/ghost\/editor\/$/, 'Landed on the correct URL'); + }); + + casper.then(function () { + test.assertExists('.tags-input-list', 'should have tag list area'); + test.assertExists('#tag-input', 'should have tag input'); + }); + + casper.thenClick('#tag-input'); + casper.then(function () { + casper.sendKeys('#tag-input', testTag, {keepFocus: true}); + }); + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Enter, {keepFocus: true}); + }); + + casper.waitForSelector(createdTag, function onSuccess() { + test.assertSelectorHasText(createdTag, testTag, 'typing enter after tag name should create tag'); + }); + + casper.thenClick(createdTag); + casper.waitWhileSelector(createdTag, function onSuccess() { + test.assert(true, 'clicking the tag should delete the tag'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', testTag, {keepFocus: true}); + }); + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Tab, {keepFocus: true}); + }); + + casper.waitForSelector(createdTag, function onSuccess() { + test.assertSelectorHasText(createdTag, testTag, 'typing tab after tag name should create tag'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Backspace, {keepFocus: true}); + }); + + casper.waitForSelector(createdTag + '.highlight', function onSuccess() { + test.assert(true, 'hitting backspace should highlight the last tag'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Backspace, {keepFocus: true}); + }); + + casper.waitWhileSelector(createdTag + '.highlight', function onSuccess() { + test.assert(true, 'hitting backspace on a higlighted tag should delete it'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', testTag, {keepFocus: true}); + }); + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Tab, {keepFocus: true}); + }); + + casper.waitForSelector(createdTag, function onSuccess() { + test.assertSelectorHasText(createdTag, testTag, 'typing tab after tag name should create tag'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Left, {keepFocus: true}); + }); + + casper.waitForSelector(createdTag + '.highlight', function onSuccess() { + test.assert(true, 'hitting left should highlight the last tag'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Left, {keepFocus: true}); + }); + + casper.waitWhileSelector(createdTag + '.highlight', function onSuccess() { + test.assert(true, 'hitting left on a higlighted tag should un-highlight it'); + }); + + casper.waitForSelector(createdTag, function onSuccess() { + test.assertSelectorHasText(createdTag, testTag, 'un-highlighting tag should not delete it'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Right, {keepFocus: true}); + }); + + casper.waitForSelector(createdTag + '.highlight', function onSuccess() { + test.assert(true, 'hitting right should highlight the first tag'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', casper.page.event.key.Right, {keepFocus: true}); + }); + + casper.waitWhileSelector(createdTag + '.highlight', function onSuccess() { + test.assert(true, 'hitting right on a higlighted tag should un-highlight it'); + }); + + casper.waitForSelector(createdTag, function onSuccess() { + test.assertSelectorHasText(createdTag, testTag, 'un-highlighting tag should not delete it'); + }); + + casper.thenClick(createdTag); + casper.waitWhileSelector(createdTag, function onSuccess() { + test.assert(true, 'clicking the tag should delete the tag'); + }); + + casper.then(function () { + casper.sendKeys('#tag-input', testTag, {keepFocus: true}); + }); + + // Click in a different field + casper.thenClick('#post-setting-date'); + + casper.waitForSelector(createdTag, function onSuccess() { + test.assertSelectorHasText(createdTag, testTag, 'de-focusing from tag input should create tag with leftover text'); + }); +});