Replace the current tag input with a selectize based input

issue #3800, closes #5648
- uses ember-cli-selectize addon for the tag editing functionality in the PSM
This commit is contained in:
Kevin Ansfield 2015-08-19 10:16:32 +01:00
parent d484c1363b
commit 3efdfdf79f
8 changed files with 65 additions and 693 deletions

View File

@ -1,281 +0,0 @@
/* 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:select', Ember.run.bind(this, 'typeaheadAdd'));
},
destroyTypeahead: function () {
this.$('#tag-input').typeahead('destroy');
},
reloadTypeahead: function (refocus) {
refocus = (typeof refocus !== 'undefined') ? refocus : true; // set default refocus value
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 (event, 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;
this.$('#tag-input').typeahead('val', '');
// Prevent multiple tags with the same name occuring
if (this.get('currentTags').findBy('name', tagName)) {
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);
},
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();
}
}
}
}
});

View File

@ -188,6 +188,13 @@ export default Ember.Controller.extend(SettingsMenuMixin, {
this.set('debounceId', debounceId);
},
// live-query of all tags for tag input autocomplete
availableTags: Ember.computed(function () {
return this.get('store').filter('tag', {limit: 'all'}, function () {
return true;
});
}),
showErrors: function (errors) {
errors = Ember.isArray(errors) ? errors : [errors];
this.get('notifications').showErrors(errors);
@ -460,6 +467,45 @@ export default Ember.Controller.extend(SettingsMenuMixin, {
self.set('selectedAuthor', author);
model.rollback();
});
},
addTag: function (tagName) {
var self = this,
currentTags = this.get('model.tags'),
currentTagNames = currentTags.map(function (tag) { return tag.get('name').toLowerCase(); }),
availableTagNames = null,
tagToAdd = null;
// abort if tag is already selected
if (currentTagNames.contains(tagName.toLowerCase())) {
return;
}
this.get('availableTags').then(function (availableTags) {
availableTagNames = availableTags.map(function (tag) { return tag.get('name').toLowerCase(); });
// find existing tag or create new
if (availableTagNames.contains(tagName.toLowerCase())) {
tagToAdd = availableTags.find(function (tag) {
return tag.get('name').toLowerCase() === tagName.toLowerCase();
});
} else {
tagToAdd = self.get('store').createRecord('tag', {
name: tagName
});
}
// push tag onto post relationship
if (tagToAdd) { self.get('model.tags').pushObject(tagToAdd); }
});
},
removeTag: function (tag) {
this.get('model.tags').removeObject(tag);
if (tag.get('isNew')) {
tag.destroyRecord();
}
}
}
});

View File

@ -128,3 +128,11 @@
.closed > .dropdown-menu {
display: none;
}
/* Selectize
/* ---------------------------------------------------------- */
.selectize-dropdown {
z-index: 200;
}

View File

@ -1,6 +0,0 @@
<ul class="tags-input-list">
{{#each currentTags as |tag index|}}
<li class="label-tag {{if (is-equal highlightIndex index) 'highlight'}}" {{action "deleteTag" tag.name}}>{{tag.name}}</li>
{{/each}}
<li><input type="text" id="tag-input"></li>
</ul>

View File

@ -35,7 +35,15 @@
<div class="form-group">
<label for="tag-input">Tags</label>
{{gh-tags-input post=model}}
{{ember-selectize
id="tag-input"
multiple=true
selection=model.tags
content=availableTags
optionValuePath="content.name"
optionLabelPath="content.name"
create-item="addTag"
remove-item="removeTag"}}
</div>
{{#unless session.user.isAuthor}}

View File

@ -25,6 +25,7 @@
"normalize.css": "3.0.3",
"password-generator": "git://github.com/bermi/password-generator#49accd7",
"rangyinputs": "1.2.0",
"selectize": "~0.12.1",
"showdown-ghost": "0.3.6",
"sinonjs": "1.14.1",
"typeahead.js": "0.11.1",

View File

@ -31,6 +31,7 @@
"ember-cli-ic-ajax": "0.1.1",
"ember-cli-inject-live-reload": "^1.3.0",
"ember-cli-mocha": "^0.7.0",
"ember-cli-selectize": "0.4.0",
"ember-cli-simple-auth": "0.8.0",
"ember-cli-simple-auth-oauth2": "0.8.0",
"ember-cli-uglify": "^1.0.1",

View File

@ -1,405 +0,0 @@
/* 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;
});
}
);