2
1
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2023-12-13 21:00:40 +01:00

Add post tagging functionality

closes #367
closes #368

- Adds Tag model with a many-to-many relationship with Post
- Adds Tag API to retrieve all previously used Tags (needed for suggestions)
- Allows setting and retrieval of Tags for a post through the Post's existing API endpoints.
- Hooks up the editor's tag suggestion box to the Ghost install's previously used tags
- Tidies the client code for adding tags, and encapsulates the functionality into a Backbone view
This commit is contained in:
Adam Howard 2013-08-21 13:55:58 +01:00
parent 6c0434fc8f
commit d90df55b75
17 changed files with 640 additions and 211 deletions

View file

@ -452,7 +452,7 @@ body.zen {
}
}
#entry-categories {
#entry-tags {
position: absolute;
top: 0;
left: 0;
@ -462,7 +462,7 @@ body.zen {
padding: 10px 0 0 0;
}
.category-label {
.tag-label {
display: block;
float: left;
@include icon($i-tag);
@ -475,7 +475,7 @@ body.zen {
}
}
.category-input {
.tag-input {
display: inline-block;
color: $lightgrey;
font-weight: 300;
@ -485,7 +485,7 @@ body.zen {
&:focus {outline: none;}
}
.category {
.tag {
@include icon-after($i-x, 8px, $darkgrey) {
margin-left: 4px;
vertical-align: 5%;
@ -503,6 +503,8 @@ body.zen {
rgba(255,255,255,0.2) 0 1px 0 inset,
#000 0 1px 3px;
@include user-select(none);
&:hover {
cursor: pointer;
@include icon-after($i-x, 8px, $lightgrey) {text-shadow: none;}
@ -521,11 +523,16 @@ body.zen {
rgba(0,0,0,0.5) 0 1px 5px;
}
li a {
padding-left: 25px;
}
mark{
background: none;
color: white;
font-weight: bold;
}
}
#entry-settings {

View file

@ -26,6 +26,20 @@
if (_.isEmpty(attrs.title)) {
return 'You must specify a title for the post.';
}
},
addTag: function (tagToAdd) {
var tags = this.get('tags') || [];
tags.push(tagToAdd);
this.set('tags', tags);
},
removeTag: function (tagToRemove) {
var tags = this.get('tags') || [];
tags = _.reject(tags, function (tag) {
return tag.id === tagToRemove.id || tag.name === tagToRemove.name;
});
this.set('tags', tags);
}
});

View file

@ -0,0 +1,8 @@
/*global window, document, Ghost, $, _, Backbone */
(function () {
"use strict";
Ghost.Collections.Tags = Backbone.Collection.extend({
url: Ghost.settings.apiRoot + '/tags'
});
}());

View file

@ -1,181 +0,0 @@
// ## Tag Selector UI
/*global window, document, $ */
(function () {
"use strict";
var suggestions,
categoryOffset,
existingTags = [], // This will be replaced by an API return.
keys = {
UP: 38,
DOWN: 40,
ESC: 27,
ENTER: 13,
COMMA: 188,
BACKSPACE: 8
};
function findTerms(searchTerm, array) {
searchTerm = searchTerm.toUpperCase();
return $.map(array, function (item) {
var match = item.toUpperCase().indexOf(searchTerm) !== -1;
return match ? item : null;
});
}
function showSuggestions($target, searchTerm) {
suggestions.show();
var results = findTerms(searchTerm, existingTags),
pos = $target.position(),
styles = {
left: pos.left
},
maxSuggestions = 5, // Limit the suggestions number
results_length = results.length,
i,
suggest;
suggestions.css(styles);
suggestions.html("");
if (results_length < maxSuggestions) {
maxSuggestions = results_length;
}
for (i = 0; i < maxSuggestions; i += 1) {
suggestions.append("<li><a href='#'>" + results[i] + "</a></li>");
}
suggest = $('ul.suggestions li a:contains("' + searchTerm + '")');
suggest.each(function () {
var src_str = $(this).html(),
term = searchTerm,
pattern;
term = term.replace(/(\s+)/, "(<[^>]+>)*$1(<[^>]+>)*");
pattern = new RegExp("(" + term + ")", "i");
src_str = src_str.replace(pattern, "<mark>$1</mark>");
/*jslint regexp: true */ // - would like to remove this
src_str = src_str.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1</mark>$2<mark>$4");
$(this).html(src_str);
});
}
function handleTagKeyup(e) {
var $target = $(e.currentTarget),
searchTerm = $.trim($target.val()).toLowerCase(),
category,
populator;
if (e.keyCode === keys.UP) {
e.preventDefault();
if (suggestions.is(":visible")) {
if (suggestions.children(".selected").length === 0) {
suggestions.find("li:last-child").addClass('selected');
} else {
suggestions.children(".selected").removeClass('selected').prev().addClass('selected');
}
}
} else if (e.keyCode === keys.DOWN) {
e.preventDefault();
if (suggestions.is(":visible")) {
if (suggestions.children(".selected").length === 0) {
suggestions.find("li:first-child").addClass('selected');
} else {
suggestions.children(".selected").removeClass('selected').next().addClass('selected');
}
}
} else if (e.keyCode === keys.ESC) {
suggestions.hide();
} else if ((e.keyCode === keys.ENTER || e.keyCode === keys.COMMA) && searchTerm) {
// Submit tag using enter or comma key
e.preventDefault();
if (suggestions.is(":visible") && suggestions.children(".selected").length !== 0) {
if ($('.category:containsExact("' + suggestions.children(".selected").text() + '")').length === 0) {
category = $('<span class="category">' + suggestions.children(".selected").text() + '</span>');
if ($target.data('populate')) {
populator = $($target.data('populate'));
populator.append(category);
}
}
suggestions.hide();
} else {
if (e.keyCode === keys.COMMA) {
searchTerm = searchTerm.replace(",", "");
} // Remove comma from string if comma is uses to submit.
if ($('.category:containsExact("' + searchTerm + '")').length === 0) {
category = $('<span class="category">' + searchTerm + '</span>');
if ($target.data('populate')) {
populator = $($target.data('populate'));
populator.append(category);
}
}
}
$target.val('').focus();
searchTerm = ""; // Used to reset search term
suggestions.hide();
}
if (e.keyCode === keys.UP || e.keyCode === keys.DOWN) {
return false;
}
if (searchTerm) {
showSuggestions($target, searchTerm);
} else {
suggestions.hide();
}
}
function handleTagKeyDown(e) {
var $target = $(e.currentTarget),
populator,
lastBlock;
// Delete character tiggers on Keydown, so needed to check on that event rather than Keyup.
if (e.keyCode === keys.BACKSPACE && !$target.val()) {
populator = $($target.data('populate'));
lastBlock = populator.find('.category').last();
lastBlock.remove();
}
}
function handleSuggestionClick(e) {
var $target = $(e.currentTarget),
category = $('<span class="category">' + $(e.currentTarget).text() + '</span>'),
populator;
if ($target.parent().data('populate')) {
populator = $($target.parent().data('populate'));
populator.append(category);
suggestions.hide();
$('[data-input-behaviour="tag"]').val('').focus();
}
}
function handleCategoryClick(e) {
$(e.currentTarget).remove();
}
$(document).ready(function () {
suggestions = $("ul.suggestions").hide(); // Initnialise suggestions overlay
if ($('.category-input').length) {
categoryOffset = $('.category-input').offset().left;
$('.category-blocks').css({'left': categoryOffset + 'px'});
}
$('[data-input-behaviour="tag"]')
.on('keyup', handleTagKeyup)
.on('keydown', handleTagKeyDown);
$('ul.suggestions').on('click', "li", handleSuggestionClick);
$('.categories').on('click', ".category", handleCategoryClick);
});
}());

View file

@ -0,0 +1,238 @@
// The Tag UI area associated with a post
/*global window, document, $, _, Backbone, Ghost */
(function () {
"use strict";
Ghost.View.EditorTagWidget = Ghost.View.extend({
events: {
'keyup [data-input-behaviour="tag"]': 'handleKeyup',
'keydown [data-input-behaviour="tag"]': 'handleKeydown',
'click ul.suggestions li': 'handleSuggestionClick',
'click .tags .tag': 'handleTagClick'
},
keys: {
UP: 38,
DOWN: 40,
ESC: 27,
ENTER: 13,
COMMA: 188,
BACKSPACE: 8
},
initialize: function () {
var self = this,
tagCollection = new Ghost.Collections.Tags();
tagCollection.fetch().then(function () {
self.allGhostTags = tagCollection.toJSON();
});
this.model.on('willSave', this.completeCurrentTag, this);
},
render: function () {
var tags = this.model.get('tags'),
$tags = $('.tags'),
tagOffset;
$tags.empty();
if (tags) {
_.forEach(tags, function (tag) {
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
$tags.append($tag);
});
}
this.$suggestions = $("ul.suggestions").hide(); // Initialise suggestions overlay
if ($tags.length) {
tagOffset = $('.tag-input').offset().left;
$('.tag-blocks').css({'left': tagOffset + 'px'});
}
return this;
},
showSuggestions: function ($target, searchTerm) {
this.$suggestions.show();
var results = this.findMatchingTags(searchTerm),
styles = {
left: $target.position().left
},
maxSuggestions = 5, // Limit the suggestions number
results_length = results.length,
i,
suggest;
this.$suggestions.css(styles);
this.$suggestions.html("");
if (results_length < maxSuggestions) {
maxSuggestions = results_length;
}
for (i = 0; i < maxSuggestions; i += 1) {
this.$suggestions.append("<li data-tag-id='" + results[i].id + "' data-tag-name='" + results[i].name + "'><a href='#'>" + results[i].name + "</a></li>");
}
suggest = $('ul.suggestions li a:contains("' + searchTerm + '")');
suggest.each(function () {
var src_str = $(this).html(),
term = searchTerm,
pattern;
term = term.replace(/(\s+)/, "(<[^>]+>)*$1(<[^>]+>)*");
pattern = new RegExp("(" + term + ")", "i");
src_str = src_str.replace(pattern, "<mark>$1</mark>");
/*jslint regexp: true */ // - would like to remove this
src_str = src_str.replace(/(<mark>[^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1</mark>$2<mark>$4");
$(this).html(src_str);
});
},
handleKeyup: function (e) {
var $target = $(e.currentTarget),
searchTerm = $.trim($target.val()).toLowerCase(),
tag,
$selectedSuggestion;
if (e.keyCode === this.keys.UP) {
e.preventDefault();
if (this.$suggestions.is(":visible")) {
if (this.$suggestions.children(".selected").length === 0) {
this.$suggestions.find("li:last-child").addClass('selected');
} else {
this.$suggestions.children(".selected").removeClass('selected').prev().addClass('selected');
}
}
} else if (e.keyCode === this.keys.DOWN) {
e.preventDefault();
if (this.$suggestions.is(":visible")) {
if (this.$suggestions.children(".selected").length === 0) {
this.$suggestions.find("li:first-child").addClass('selected');
} else {
this.$suggestions.children(".selected").removeClass('selected').next().addClass('selected');
}
}
} else if (e.keyCode === this.keys.ESC) {
this.$suggestions.hide();
} else if ((e.keyCode === this.keys.ENTER || e.keyCode === this.keys.COMMA) && searchTerm) {
// Submit tag using enter or comma key
e.preventDefault();
$selectedSuggestion = this.$suggestions.children(".selected");
if (this.$suggestions.is(":visible") && $selectedSuggestion.length !== 0) {
if ($('.tag:containsExact("' + $selectedSuggestion.data('tag-name') + '")').length === 0) {
tag = {id: $selectedSuggestion.data('tag-id'), name: $selectedSuggestion.data('tag-name')};
this.addTag(tag);
}
} else {
if (e.keyCode === this.keys.COMMA) {
searchTerm = searchTerm.replace(/,/g, "");
} // Remove comma from string if comma is used to submit.
if ($('.tag:containsExact("' + searchTerm + '")').length === 0) {
this.addTag({id: null, name: searchTerm});
}
}
$target.val('').focus();
searchTerm = ""; // Used to reset search term
this.$suggestions.hide();
}
if (e.keyCode === this.keys.UP || e.keyCode === this.keys.DOWN) {
return false;
}
if (searchTerm) {
this.showSuggestions($target, searchTerm);
} else {
this.$suggestions.hide();
}
},
handleKeydown: function (e) {
var $target = $(e.currentTarget),
lastBlock,
tag;
// Delete character tiggers on Keydown, so needed to check on that event rather than Keyup.
if (e.keyCode === this.keys.BACKSPACE && !$target.val()) {
lastBlock = this.$('.tags').find('.tag').last();
lastBlock.remove();
tag = {id: lastBlock.data('tag-id'), name: lastBlock.text()};
this.model.removeTag(tag);
}
},
completeCurrentTag: function () {
var $target = this.$('.tag-input'),
tagName = $target.val(),
usedTagNames,
hasAlreadyBeenAdded;
usedTagNames = _.map(this.model.get('tags'), function (tag) {
return tag.name.toUpperCase();
});
hasAlreadyBeenAdded = usedTagNames.indexOf(tagName.toUpperCase()) !== -1;
if (tagName.length > 0 && !hasAlreadyBeenAdded) {
this.addTag({id: null, name: tagName});
}
},
handleSuggestionClick: function (e) {
var $target = $(e.currentTarget);
if (e) { e.preventDefault(); }
this.addTag({id: $target.data('tag-id'), name: $target.data('tag-name')});
},
handleTagClick: function (e) {
var $tag = $(e.currentTarget),
tag = {id: $tag.data('tag-id'), name: $tag.text()};
$tag.remove();
this.model.removeTag(tag);
},
findMatchingTags: function (searchTerm) {
var matchingTagModels,
self = this;
if (!this.allGhostTags) {
return [];
}
searchTerm = searchTerm.toUpperCase();
matchingTagModels = _.filter(this.allGhostTags, function (tag) {
var tagNameMatches,
hasAlreadyBeenAdded;
tagNameMatches = tag.name.toUpperCase().indexOf(searchTerm) !== -1;
hasAlreadyBeenAdded = _.some(self.model.get('tags'), function (usedTag) {
return tag.name.toUpperCase() === usedTag.name.toUpperCase();
});
return tagNameMatches && !hasAlreadyBeenAdded;
});
return matchingTagModels;
},
addTag: function (tag) {
var $tag = $('<span class="tag" data-tag-id="' + tag.id + '">' + tag.name + '</span>');
this.$('.tags').append($tag);
this.model.addTag(tag);
this.$('.tag-input').val('').focus();
this.$suggestions.hide();
}
});
}());

View file

@ -5,7 +5,6 @@
"use strict";
var PublishBar,
TagWidget,
ActionsWidget,
MarkdownShortcuts = [
{'key': 'Ctrl+B', 'style': 'bold'},
@ -40,7 +39,7 @@
PublishBar = Ghost.View.extend({
initialize: function () {
this.addSubview(new TagWidget({el: this.$('#entry-categories'), model: this.model})).render();
this.addSubview(new Ghost.View.EditorTagWidget({el: this.$('#entry-tags'), model: this.model})).render();
this.addSubview(new ActionsWidget({el: this.$('#entry-actions'), model: this.model})).render();
},
@ -48,12 +47,6 @@
});
// The Tag UI area associated with a post
// ----------------------------------------
TagWidget = Ghost.View.extend({
render: function () { return this; }
});
// The Publish, Queue, Publish Now buttons
// ----------------------------------------
ActionsWidget = Ghost.View.extend({
@ -167,6 +160,8 @@
});
}
this.model.trigger('willSave');
this.savePost({
status: status
}).then(function () {

View file

@ -12,6 +12,7 @@ var Ghost = require('../ghost'),
dataProvider = ghost.dataProvider,
posts,
users,
tags,
notifications,
settings,
requestHandler,
@ -144,6 +145,16 @@ users = {
}
};
tags = {
// #### All
// **takes:** Nothing yet
all: function browse() {
// **returns:** a promise for all tags which have previously been used in a json object
return dataProvider.Tag.findAll();
}
};
// ## Notifications
notifications = {
// #### Destroy
@ -306,6 +317,7 @@ cachedSettingsRequestHandler = function (apiMethod) {
// Public API
module.exports.posts = posts;
module.exports.users = users;
module.exports.tags = tags;
module.exports.notifications = notifications;
module.exports.settings = settings;
module.exports.requestHandler = requestHandler;

View file

@ -50,6 +50,29 @@ coreHelpers = function (ghost) {
return this.author ? this.author.full_name : "";
});
// ### Tags Helper
//
// *Usage example:*
// `{{tags}}`
// `{{tags separator=" - "}}`
//
// Returns a string of the tags on the post.
// By default, tags are separated by commas.
//
// Note that the standard {{#each tags}} implementation is unaffected by this helper
// and can be used for more complex templates.
ghost.registerThemeHelper('tags', function (options) {
var separator = ", ",
tagNames;
if (typeof options.hash.separator === "string") {
separator = options.hash.separator;
}
tagNames = _.pluck(this.tags, 'name');
return tagNames.join(separator);
});
// ### Content Helper
//
// *Usage example:*
@ -128,7 +151,10 @@ coreHelpers = function (ghost) {
ghost.registerThemeHelper('post_class', function (options) {
var classes = ['post'];
// TODO: add tag names once we have them
if (this.tags) {
classes = classes.concat(this.tags.map(function (tag) { return "tag-" + tag.name; }));
}
return ghost.doFilter('post_class', classes, function (classes) {
var classString = _.reduce(classes, function (memo, item) { return memo + ' ' + item; }, '');
return new hbs.handlebars.SafeString(classString.trim());

View file

@ -39,7 +39,9 @@ GhostBookshelf.Model = GhostBookshelf.Model.extend({
}
_.each(relations, function (relation, key) {
if (key.substring(0, 7) !== "_pivot_") {
attrs[key] = relation.toJSON ? relation.toJSON() : relation;
}
});
return attrs;

View file

@ -6,6 +6,7 @@ module.exports = {
Role: require('./role').Role,
Permission: require('./permission').Permission,
Settings: require('./settings').Settings,
Tag: require('./tag').Tag,
init: function () {
return migrations.init();
},

View file

@ -8,6 +8,7 @@ var Post,
github = require('../../shared/vendor/showdown/extensions/github'),
converter = new Showdown.converter({extensions: [github]}),
User = require('./user').User,
Tag = require('./tag').Tag,
GhostBookshelf = require('./base');
Post = GhostBookshelf.Model.extend({
@ -32,6 +33,7 @@ Post = GhostBookshelf.Model.extend({
initialize: function () {
this.on('creating', this.creating, this);
this.on('saving', this.updateTags, this);
this.on('saving', this.saving, this);
this.on('saving', this.validate, this);
},
@ -57,7 +59,9 @@ Post = GhostBookshelf.Model.extend({
}
this.set('updated_by', 1);
// refactoring of ghost required in order to make these details available here
},
creating: function () {
@ -134,12 +138,77 @@ Post = GhostBookshelf.Model.extend({
return checkIfSlugExists(slug);
},
updateTags: function () {
var self = this,
tagOperations = [],
newTags = this.get('tags'),
tagsToDetach,
existingTagIDs,
tagsToCreateAndAdd,
tagsToAddByID,
fetchOperation;
if (!newTags) {
return;
}
fetchOperation = Post.forge({id: this.id}).fetch({withRelated: ['tags']});
return fetchOperation.then(function (thisModelWithTags) {
var existingTags = thisModelWithTags.related('tags').models;
tagsToDetach = existingTags.filter(function (existingTag) {
var tagStillRemains = newTags.some(function (newTag) {
return newTag.id === existingTag.id;
});
return !tagStillRemains;
});
if (tagsToDetach.length > 0) {
tagOperations.push(self.tags().detach(tagsToDetach));
}
// Detect any tags that have been added by ID
existingTagIDs = existingTags.map(function (existingTag) {
return existingTag.id;
});
tagsToAddByID = newTags.filter(function (newTag) {
return existingTagIDs.indexOf(newTag.id) === -1;
});
if (tagsToAddByID.length > 0) {
tagsToAddByID = _.pluck(tagsToAddByID, 'id');
tagOperations.push(self.tags().attach(tagsToAddByID));
}
// Detect any tags that have been added, but don't already exist in the database
tagsToCreateAndAdd = newTags.filter(function (newTag) {
return newTag.id === null || newTag.id === undefined;
});
tagsToCreateAndAdd.forEach(function (tagToCreateAndAdd) {
var createAndAddOperation = Tag.add({name: tagToCreateAndAdd.name}).then(function (createdTag) {
return self.tags().attach(createdTag.id);
});
tagOperations.push(createAndAddOperation);
});
return when.all(tagOperations);
});
},
// Relations
user: function () {
return this.belongsTo(User, 'created_by');
},
author: function () {
return this.belongsTo(User, 'author_id');
},
tags: function () {
return this.belongsToMany(Tag);
}
}, {
@ -148,7 +217,7 @@ Post = GhostBookshelf.Model.extend({
// Extends base model findAll to eager-fetch author and user relationships.
findAll: function (options) {
options = options || {};
options.withRelated = [ "author", "user" ];
options.withRelated = [ "author", "user", "tags" ];
return GhostBookshelf.Model.findAll.call(this, options);
},
@ -156,7 +225,7 @@ Post = GhostBookshelf.Model.extend({
// Extends base model findOne to eager-fetch author and user relationships.
findOne: function (args, options) {
options = options || {};
options.withRelated = [ "author", "user" ];
options.withRelated = [ "author", "user", "tags" ];
return GhostBookshelf.Model.findOne.call(this, args, options);
},
@ -210,7 +279,7 @@ Post = GhostBookshelf.Model.extend({
postCollection.query('where', opts.where);
}
opts.withRelated = [ "author", "user" ];
opts.withRelated = [ "author", "user", "tags" ];
// Set the limit & offset for the query, fetching
// with the opts (to specify any eager relations, etc.)

23
core/server/models/tag.js Normal file
View file

@ -0,0 +1,23 @@
var Tag,
Tags,
Posts = require('./post').Posts,
GhostBookshelf = require('./base');
Tag = GhostBookshelf.Model.extend({
tableName: 'tags',
posts: function () {
return this.belongsToMany(Posts);
}
});
Tags = GhostBookshelf.Collection.extend({
model: Tag
});
module.exports = {
Tag: Tag,
Tags: Tags
};

View file

@ -72,12 +72,12 @@
<script src="/public/mobile-interactions.js"></script>
<script src="/public/toggle.js"></script>
<script src="/public/markdown-actions.js"></script>
<script src="/public/tagui.js"></script>
<script src="/public/helpers/index.js"></script>
<!-- // require '/public/models/*' -->
<script src="/public/models/post.js"></script>
<script src="/public/models/user.js"></script>
<script src="/public/models/tag.js"></script>
<script src="/public/models/widget.js"></script>
<script src="/public/models/settings.js"></script>
<!-- // require '/public/views/*' -->
@ -85,6 +85,7 @@
<script src="/public/views/dashboard.js"></script>
<script src="/public/views/blog.js"></script>
<script src="/public/views/editor.js"></script>
<script src="/public/views/editor-tag-widget.js"></script>
<script src="/public/views/login.js"></script>
<script src="/public/views/settings.js"></script>
<script src="/public/views/debug.js"></script>

View file

@ -32,13 +32,12 @@
</section>
<footer id="publish-bar">
<nav>
<section id="entry-categories" href="#" class="left">
<label class="category-label" for="categories"><span class="hidden">Categories</span></label>
<div class="categories"></div>
<input type="hidden" class="category-holder" id="category-holder">
<input class="category-input" id="categories" type="text"
data-populate-hidden="#category-holder" data-input-behaviour="tag" data-populate=".categories" />
<ul class="suggestions overlay" data-populate=".categories"></ul>
<section id="entry-tags" href="#" class="left">
<label class="tag-label" for="tags"><span class="hidden">Tags</span></label>
<div class="tags"></div>
<input type="hidden" class="tags-holder" id="tags-holder">
<input class="tag-input" id="tags" type="text" data-input-behaviour="tag" />
<ul class="suggestions overlay"></ul>
</section>
<div class="right">
<section id="entry-actions" class="splitbutton-save">

View file

@ -5,7 +5,7 @@
"admin.navbar.settings": "Settings",
"__SECTION__": "icons",
"icon.category.label": "Category",
"icon.tag.label": "Tag",
"icon.faq.label": "?",
"icon.faq.markdown.title": "What is Markdown?",
"icon.full_screen.label": "Full Screen",

View file

@ -0,0 +1,213 @@
/*globals describe, beforeEach, it */
var _ = require("underscore"),
when = require('when'),
sequence = require('when/sequence'),
should = require('should'),
helpers = require('./helpers'),
Models = require('../../server/models');
describe('Tag Model', function () {
var TagModel = Models.Tag;
before(function (done) {
helpers.clearData().then(function () {
done();
}, done);
});
beforeEach(function (done) {
this.timeout(5000);
helpers.initData()
.then(function () {
})
.then(function () {
done();
}, done);
});
afterEach(function (done) {
helpers.clearData().then(function () {
done();
}, done);
});
describe('a Post', function () {
var PostModel = Models.Post;
it('can add a tag', function (done) {
var newPost = {title: 'Test Title 1', content_raw: 'Test Content 1'},
newTag = {name: 'tag1'},
createdPostID;
when.all([
PostModel.add(newPost),
TagModel.add(newTag)
]).then(function (models) {
var createdPost = models[0],
createdTag = models[1];
createdPostID = createdPost.id;
return createdPost.tags().attach(createdTag);
}).then(function () {
return PostModel.read({id: createdPostID}, { withRelated: ['tags']});
}).then(function (postWithTag) {
postWithTag.related('tags').length.should.equal(1);
done();
}).then(null, done);
});
it('can remove a tag', function (done) {
// The majority of this test is ripped from above, which is obviously a Bad Thing.
// Would be nice to find a way to seed data with relations for cases like this,
// because there are more DB hits than needed
var newPost = {title: 'Test Title 1', content_raw: 'Test Content 1'},
newTag = {name: 'tag1'},
createdTagID,
createdPostID;
when.all([
PostModel.add(newPost),
TagModel.add(newTag)
]).then(function (models) {
var createdPost = models[0],
createdTag = models[1];
createdPostID = createdPost.id;
createdTagID = createdTag.id;
return createdPost.tags().attach(createdTag);
}).then(function () {
return PostModel.read({id: createdPostID}, { withRelated: ['tags']});
}).then(function (postWithTag) {
return postWithTag.tags().detach(createdTagID);
}).then(function () {
return PostModel.read({id: createdPostID}, { withRelated: ['tags']});
}).then(function (postWithoutTag) {
postWithoutTag.related('tags').should.be.empty;
done();
}).then(null, done);
});
describe('setting tags from an array on update', function () {
// When a Post is updated, iterate through the existing tags, and detach any that have since been removed.
// It can be assumed that any remaining tags in the update data are newly added.
// Create new tags if needed, and attach them to the Post
function seedTags (tagNames) {
var createOperations = [
PostModel.add({title: 'title', content_raw: 'content'})
];
var tagModels = tagNames.map(function (tagName) { return TagModel.add({name: tagName}) });
createOperations = createOperations.concat(tagModels);
return when.all(createOperations).then(function (models) {
var postModel = models[0],
attachOperations;
attachOperations = [];
for (var i = 1; i < models.length; i++) {
attachOperations.push(postModel.tags().attach(models[i]))
};
return when.all(attachOperations).then(function () {
return postModel;
});
}).then(function (postModel) {
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
});
}
it('does nothing if tags havent changed', function (done) {
var seededTagNames = ['tag1', 'tag2', 'tag3'];
seedTags(seededTagNames).then(function (postModel) {
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
var existingTagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
postModel.set('tags', existingTagData);
return postModel.save();
}).then(function (postModel) {
var tagNames = postModel.related('tags').models.map(function (t) { return t.attributes.name });
tagNames.should.eql(seededTagNames);
return TagModel.findAll();
}).then(function (tagsFromDB) {
tagsFromDB.length.should.eql(seededTagNames.length);
done();
}).then(null, done);
});
it('detaches tags that have been removed', function (done) {
var seededTagNames = ['tag1', 'tag2', 'tag3'];
seedTags(seededTagNames).then(function (postModel) {
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
var tagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
// remove the second tag, and save
tagData.splice(1, 1);
return postModel.set('tags', tagData).save();
}).then(function (postModel) {
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
}).then(function (reloadedPost) {
var tagNames = reloadedPost.related('tags').models.map( function (t) { return t.attributes.name });
tagNames.should.eql(['tag1', 'tag3']);
done();
}).then(null, done);
});
it('attaches tags that are new to the post, but aleady exist in the database', function (done) {
var seededTagNames = ['tag1', 'tag2'],
postModel;
seedTags(seededTagNames).then(function (_postModel) {
postModel = _postModel;
return TagModel.add({name: 'tag3'})
}).then(function() {
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
var tagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
// add the additional tag, and save
tagData.push({id: 3, name: 'tag3'});
return postModel.set('tags', tagData).save();
}).then(function () {
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
}).then(function (reloadedPost) {
var tagModels = reloadedPost.related('tags').models,
tagNames = tagModels.map( function (t) { return t.attributes.name });
tagNames.should.eql(['tag1', 'tag2', 'tag3']);
tagModels[2].id.should.eql(3); // make sure it hasn't just added a new tag with the same name
done();
}).then(null, done);
});
it('creates and attaches tags that are new to the Tags table', function (done) {
var seededTagNames = ['tag1', 'tag2'];
seedTags(seededTagNames).then(function (postModel) {
// the tag API expects tags to be provided like {id: 1, name: 'draft'}
var tagData = seededTagNames.map(function (tagName, i) { return {id: i+1, name: tagName} });
// add the additional tag, and save
tagData.push({id: null, name: 'tag3'});
return postModel.set('tags', tagData).save();
}).then(function (postModel) {
return PostModel.read({id: postModel.id}, { withRelated: ['tags']});
}).then(function (reloadedPost) {
var tagNames = reloadedPost.related('tags').models.map( function (t) { return t.attributes.name });
tagNames.should.eql(['tag1', 'tag2', 'tag3']);
done();
}).then(null, done);
});
});
});
});

View file

@ -203,6 +203,8 @@ when.all([ghost.init(), filters.loadCoreFilters(ghost), helpers.loadCoreHelpers(
ghost.app().get('/api/v0.1/users', authAPI, disableCachedResult, api.requestHandler(api.users.browse));
ghost.app().get('/api/v0.1/users/:id', authAPI, disableCachedResult, api.requestHandler(api.users.read));
ghost.app().put('/api/v0.1/users/:id', authAPI, disableCachedResult, api.requestHandler(api.users.edit));
// #### Tags
ghost.app().get('/api/v0.1/tags', authAPI, disableCachedResult, api.requestHandler(api.tags.all));
// #### Notifications
ghost.app().del('/api/v0.1/notifications/:id', authAPI, disableCachedResult, api.requestHandler(api.notifications.destroy));
ghost.app().post('/api/v0.1/notifications/', authAPI, disableCachedResult, api.requestHandler(api.notifications.add));