From d90df55b75a4607967961000f0444ed1dbdf2c8c Mon Sep 17 00:00:00 2001 From: Adam Howard Date: Wed, 21 Aug 2013 13:55:58 +0100 Subject: [PATCH] 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 --- core/client/assets/sass/layouts/editor.scss | 15 +- core/client/models/post.js | 16 +- core/client/models/tag.js | 8 + core/client/tagui.js | 181 --------------- core/client/views/editor-tag-widget.js | 238 ++++++++++++++++++++ core/client/views/editor.js | 11 +- core/server/api.js | 12 + core/server/helpers/index.js | 30 ++- core/server/models/base.js | 4 +- core/server/models/index.js | 1 + core/server/models/post.js | 75 +++++- core/server/models/tag.js | 23 ++ core/server/views/default.hbs | 3 +- core/server/views/editor.hbs | 15 +- core/shared/lang/en.json | 4 +- core/test/unit/api_tags_spec.js | 213 ++++++++++++++++++ index.js | 2 + 17 files changed, 640 insertions(+), 211 deletions(-) create mode 100644 core/client/models/tag.js delete mode 100644 core/client/tagui.js create mode 100644 core/client/views/editor-tag-widget.js create mode 100644 core/server/models/tag.js create mode 100644 core/test/unit/api_tags_spec.js diff --git a/core/client/assets/sass/layouts/editor.scss b/core/client/assets/sass/layouts/editor.scss index 235e1e78d8..58d00b12a6 100644 --- a/core/client/assets/sass/layouts/editor.scss +++ b/core/client/assets/sass/layouts/editor.scss @@ -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 { diff --git a/core/client/models/post.js b/core/client/models/post.js index 6028aab64f..97de7d5c7e 100644 --- a/core/client/models/post.js +++ b/core/client/models/post.js @@ -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); } }); @@ -46,4 +60,4 @@ } }); -}()); \ No newline at end of file +}()); diff --git a/core/client/models/tag.js b/core/client/models/tag.js new file mode 100644 index 0000000000..9ab0f9098e --- /dev/null +++ b/core/client/models/tag.js @@ -0,0 +1,8 @@ +/*global window, document, Ghost, $, _, Backbone */ +(function () { + "use strict"; + + Ghost.Collections.Tags = Backbone.Collection.extend({ + url: Ghost.settings.apiRoot + '/tags' + }); +}()); diff --git a/core/client/tagui.js b/core/client/tagui.js deleted file mode 100644 index fb1f824f9f..0000000000 --- a/core/client/tagui.js +++ /dev/null @@ -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("
  • " + results[i] + "
  • "); - } - - 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, "$1"); - /*jslint regexp: true */ // - would like to remove this - src_str = src_str.replace(/([^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1$2$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 = $('' + suggestions.children(".selected").text() + ''); - 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 = $('' + searchTerm + ''); - 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 = $('' + $(e.currentTarget).text() + ''), - 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); - - }); - -}()); \ No newline at end of file diff --git a/core/client/views/editor-tag-widget.js b/core/client/views/editor-tag-widget.js new file mode 100644 index 0000000000..430493e020 --- /dev/null +++ b/core/client/views/editor-tag-widget.js @@ -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 = $('' + tag.name + ''); + $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("
  • " + results[i].name + "
  • "); + } + + 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, "$1"); + /*jslint regexp: true */ // - would like to remove this + src_str = src_str.replace(/([^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1$2$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 = $('' + tag.name + ''); + this.$('.tags').append($tag); + this.model.addTag(tag); + + this.$('.tag-input').val('').focus(); + this.$suggestions.hide(); + } + }); + +}()); diff --git a/core/client/views/editor.js b/core/client/views/editor.js index 7dfa2705e8..1019f1b141 100644 --- a/core/client/views/editor.js +++ b/core/client/views/editor.js @@ -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 () { diff --git a/core/server/api.js b/core/server/api.js index 6c9e38a658..6dcabed2be 100644 --- a/core/server/api.js +++ b/core/server/api.js @@ -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; diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index 92cf05908f..9a416c40c9 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -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()); @@ -305,4 +331,4 @@ coreHelpers = function (ghost) { ); }; -module.exports.loadCoreHelpers = coreHelpers; \ No newline at end of file +module.exports.loadCoreHelpers = coreHelpers; diff --git a/core/server/models/base.js b/core/server/models/base.js index 02a8af6d76..911a216541 100644 --- a/core/server/models/base.js +++ b/core/server/models/base.js @@ -39,7 +39,9 @@ GhostBookshelf.Model = GhostBookshelf.Model.extend({ } _.each(relations, function (relation, key) { - attrs[key] = relation.toJSON ? relation.toJSON() : relation; + if (key.substring(0, 7) !== "_pivot_") { + attrs[key] = relation.toJSON ? relation.toJSON() : relation; + } }); return attrs; diff --git a/core/server/models/index.js b/core/server/models/index.js index 4447d028c8..9f6f25b74e 100644 --- a/core/server/models/index.js +++ b/core/server/models/index.js @@ -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(); }, diff --git a/core/server/models/post.js b/core/server/models/post.js index a8aec027cb..69890fe620 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -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.) diff --git a/core/server/models/tag.js b/core/server/models/tag.js new file mode 100644 index 0000000000..a2356647d5 --- /dev/null +++ b/core/server/models/tag.js @@ -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 +}; diff --git a/core/server/views/default.hbs b/core/server/views/default.hbs index 9100a93db5..849e3ce146 100644 --- a/core/server/views/default.hbs +++ b/core/server/views/default.hbs @@ -72,12 +72,12 @@ - + @@ -85,6 +85,7 @@ + diff --git a/core/server/views/editor.hbs b/core/server/views/editor.hbs index 9a74b34ea9..a05d5a8407 100644 --- a/core/server/views/editor.hbs +++ b/core/server/views/editor.hbs @@ -32,13 +32,12 @@ \ No newline at end of file + diff --git a/core/shared/lang/en.json b/core/shared/lang/en.json index 24218f14b9..06e3467324 100644 --- a/core/shared/lang/en.json +++ b/core/shared/lang/en.json @@ -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", @@ -23,4 +23,4 @@ "editor.actions.save_draft": "Save Draft", "editor.actions.publish": "Publish" -} \ No newline at end of file +} diff --git a/core/test/unit/api_tags_spec.js b/core/test/unit/api_tags_spec.js new file mode 100644 index 0000000000..b6ef14e4c6 --- /dev/null +++ b/core/test/unit/api_tags_spec.js @@ -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); + }); + }); + + }); + +}); diff --git a/index.js b/index.js index 6248be0fd8..20d9d32ac5 100644 --- a/index.js +++ b/index.js @@ -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));