diff --git a/.gitignore b/.gitignore index 3fb848dbb4..7d42be773b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,8 @@ !/apps/files_versions !/apps/user_ldap !/apps/user_webdavauth -!apps/provisioning_api +!/apps/provisioning_api +!/apps/systemtags /apps/files_external/3rdparty/irodsphp/PHPUnitTest /apps/files_external/3rdparty/irodsphp/web /apps/files_external/3rdparty/irodsphp/prods/test diff --git a/apps/dav/lib/systemtag/systemtagplugin.php b/apps/dav/lib/systemtag/systemtagplugin.php index 2cab9ba8d5..d81ccbf84b 100644 --- a/apps/dav/lib/systemtag/systemtagplugin.php +++ b/apps/dav/lib/systemtag/systemtagplugin.php @@ -127,7 +127,7 @@ class SystemTagPlugin extends \Sabre\DAV\ServerPlugin { $url .= '/'; } - $response->setHeader('Location', $url . $tag->getId()); + $response->setHeader('Content-Location', $url . $tag->getId()); // created $response->setStatus(201); diff --git a/apps/dav/tests/unit/systemtag/systemtagplugin.php b/apps/dav/tests/unit/systemtag/systemtagplugin.php index e0fbd40f5b..1d22af7518 100644 --- a/apps/dav/tests/unit/systemtag/systemtagplugin.php +++ b/apps/dav/tests/unit/systemtag/systemtagplugin.php @@ -201,7 +201,7 @@ class SystemTagPlugin extends \Test\TestCase { $response->expects($this->once()) ->method('setHeader') - ->with('Location', 'http://example.com/dav/systemtags/1'); + ->with('Content-Location', 'http://example.com/dav/systemtags/1'); $this->plugin->httpPost($request, $response); } @@ -266,7 +266,7 @@ class SystemTagPlugin extends \Test\TestCase { $response->expects($this->once()) ->method('setHeader') - ->with('Location', 'http://example.com/dav/systemtags/1'); + ->with('Content-Location', 'http://example.com/dav/systemtags/1'); $this->plugin->httpPost($request, $response); } diff --git a/apps/systemtags/appinfo/app.php b/apps/systemtags/appinfo/app.php new file mode 100644 index 0000000000..d07902f777 --- /dev/null +++ b/apps/systemtags/appinfo/app.php @@ -0,0 +1,40 @@ + + * + * @copyright Copyright (c) 2015, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +$eventDispatcher = \OC::$server->getEventDispatcher(); +$eventDispatcher->addListener( + 'OCA\Files::loadAdditionalScripts', + function() { + // FIXME: no public API for these ? + \OC_Util::addVendorScript('select2/select2'); + \OC_Util::addVendorStyle('select2/select2'); + \OCP\Util::addScript('select2-toggleselect'); + \OCP\Util::addScript('oc-backbone-webdav'); + \OCP\Util::addScript('systemtags/systemtagmodel'); + \OCP\Util::addScript('systemtags/systemtagsmappingcollection'); + \OCP\Util::addScript('systemtags/systemtagscollection'); + \OCP\Util::addScript('systemtags/systemtagsinputfield'); + \OCP\Util::addScript('systemtags', 'app'); + \OCP\Util::addScript('systemtags', 'filesplugin'); + \OCP\Util::addScript('systemtags', 'systemtagsinfoview'); + \OCP\Util::addStyle('systemtags'); + } +); diff --git a/apps/systemtags/appinfo/info.xml b/apps/systemtags/appinfo/info.xml new file mode 100644 index 0000000000..f2464f0413 --- /dev/null +++ b/apps/systemtags/appinfo/info.xml @@ -0,0 +1,18 @@ + + + systemtags + System tags + System-wide tags user interface + AGPL + Vincent Petry + true + + + 0.1 + + + + + user-systemtags + + diff --git a/apps/systemtags/js/app.js b/apps/systemtags/js/app.js new file mode 100644 index 0000000000..f55aa5c9a6 --- /dev/null +++ b/apps/systemtags/js/app.js @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2015 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function() { + if (!OCA.SystemTags) { + /** + * @namespace + */ + OCA.SystemTags = {}; + } + +})(); + diff --git a/apps/systemtags/js/filesplugin.js b/apps/systemtags/js/filesplugin.js new file mode 100644 index 0000000000..471440c2e0 --- /dev/null +++ b/apps/systemtags/js/filesplugin.js @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2015 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function() { + OCA.SystemTags = _.extend({}, OCA.SystemTags); + if (!OCA.SystemTags) { + /** + * @namespace + */ + OCA.SystemTags = {}; + } + + /** + * @namespace + */ + OCA.SystemTags.FilesPlugin = { + allowedLists: [ + 'files', + 'favorites' + ], + + attach: function(fileList) { + if (this.allowedLists.indexOf(fileList.id) < 0) { + return; + } + + fileList.registerDetailView(new OCA.SystemTags.SystemTagsInfoView()); + } + }; + +})(); + +OC.Plugins.register('OCA.Files.FileList', OCA.SystemTags.FilesPlugin); + diff --git a/apps/systemtags/js/systemtagsinfoview.js b/apps/systemtags/js/systemtagsinfoview.js new file mode 100644 index 0000000000..b1820bfcd9 --- /dev/null +++ b/apps/systemtags/js/systemtagsinfoview.js @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2015 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function(OCA) { + /** + * @class OCA.SystemTags.SystemTagsInfoView + * @classdesc + * + * Displays a file's system tags + * + */ + var SystemTagsInfoView = OCA.Files.DetailFileInfoView.extend( + /** @lends OCA.SystemTags.SystemTagsInfoView.prototype */ { + + _rendered: false, + + className: 'systemTagsInfoView hidden', + + /** + * @type OC.SystemTags.SystemTagsInputField + */ + _inputView: null, + + initialize: function(options) { + var self = this; + options = options || {}; + + this._inputView = new OC.SystemTags.SystemTagsInputField({ + multiple: true, + allowActions: true, + allowCreate: true, + initSelection: function(element, callback) { + callback(self.selectedTagsCollection.toJSON()); + } + }); + + this.selectedTagsCollection = new OC.SystemTags.SystemTagsMappingCollection([], {objectType: 'files'}); + + this._inputView.collection.on('change:name', this._onTagRenamedGlobally, this); + this._inputView.collection.on('remove', this._onTagDeletedGlobally, this); + + this._inputView.on('select', this._onSelectTag, this); + this._inputView.on('deselect', this._onDeselectTag, this); + }, + + /** + * Event handler whenever a tag was selected + */ + _onSelectTag: function(tag) { + // create a mapping entry for this tag + this.selectedTagsCollection.create(tag.toJSON()); + }, + + /** + * Event handler whenever a tag gets deselected. + * Removes the selected tag from the mapping collection. + * + * @param {string} tagId tag id + */ + _onDeselectTag: function(tagId) { + this.selectedTagsCollection.get(tagId).destroy(); + }, + + /** + * Event handler whenever a tag was renamed globally. + * + * This will automatically adjust the tag mapping collection to + * container the new name. + * + * @param {OC.Backbone.Model} changedTag tag model that has changed + */ + _onTagRenamedGlobally: function(changedTag) { + // also rename it in the selection, if applicable + var selectedTagMapping = this.selectedTagsCollection.get(changedTag.id); + if (selectedTagMapping) { + selectedTagMapping.set(changedTag.toJSON()); + } + }, + + /** + * Event handler whenever a tag was deleted globally. + * + * This will automatically adjust the tag mapping collection to + * container the new name. + * + * @param {OC.Backbone.Model} changedTag tag model that has changed + */ + _onTagDeletedGlobally: function(tagId) { + // also rename it in the selection, if applicable + this.selectedTagsCollection.remove(tagId); + }, + + setFileInfo: function(fileInfo) { + var self = this; + if (!this._rendered) { + this.render(); + } + + if (fileInfo) { + this.selectedTagsCollection.setObjectId(fileInfo.id); + this.selectedTagsCollection.fetch({ + success: function(collection) { + collection.fetched = true; + self._inputView.setData(collection.toJSON()); + self.$el.removeClass('hidden'); + } + }); + } + this.$el.addClass('hidden'); + }, + + /** + * Renders this details view + */ + render: function() { + this.$el.append(this._inputView.$el); + this._inputView.render(); + }, + + remove: function() { + this._inputView.remove(); + } + }); + + OCA.SystemTags.SystemTagsInfoView = SystemTagsInfoView; + +})(OCA); + diff --git a/apps/systemtags/tests/js/systemtagsinfoviewSpec.js b/apps/systemtags/tests/js/systemtagsinfoviewSpec.js new file mode 100644 index 0000000000..971ad8fc17 --- /dev/null +++ b/apps/systemtags/tests/js/systemtagsinfoviewSpec.js @@ -0,0 +1,149 @@ +/** +* ownCloud +* +* @author Vincent Petry +* @copyright 2016 Vincent Petry +* +* This library is free software; you can redistribute it and/or +* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +* License as published by the Free Software Foundation; either +* version 3 of the License, or any later version. +* +* This library is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU AFFERO GENERAL PUBLIC LICENSE for more details. +* +* You should have received a copy of the GNU Affero General Public +* License along with this library. If not, see . +* +*/ + +describe('OCA.SystemTags.SystemTagsInfoView tests', function() { + var view; + + beforeEach(function() { + view = new OCA.SystemTags.SystemTagsInfoView(); + $('#testArea').append(view.$el); + }); + afterEach(function() { + view.remove(); + view = undefined; + }); + describe('rendering', function() { + it('renders input field view', function() { + view.render(); + expect(view.$el.find('input[name=tags]').length).toEqual(1); + }); + it('fetches selected tags then renders when setting file info', function() { + var fetchStub = sinon.stub(OC.SystemTags.SystemTagsMappingCollection.prototype, 'fetch'); + var setDataStub = sinon.stub(OC.SystemTags.SystemTagsInputField.prototype, 'setData'); + + expect(view.$el.hasClass('hidden')).toEqual(true); + + view.setFileInfo({id: '123'}); + expect(view.$el.find('input[name=tags]').length).toEqual(1); + + expect(fetchStub.calledOnce).toEqual(true); + expect(view.selectedTagsCollection.url()) + .toEqual(OC.linkToRemote('dav') + '/systemtags-relations/files/123'); + + view.selectedTagsCollection.add([ + {id: '1', name: 'test1'}, + {id: '3', name: 'test3'} + ]); + + fetchStub.yieldTo('success', view.selectedTagsCollection); + expect(setDataStub.calledOnce).toEqual(true); + expect(setDataStub.getCall(0).args[0]).toEqual([{ + id: '1', name: 'test1', userVisible: true, userAssignable: true + }, { + id: '3', name: 'test3', userVisible: true, userAssignable: true + }]); + + expect(view.$el.hasClass('hidden')).toEqual(false); + + fetchStub.restore(); + setDataStub.restore(); + }); + it('overrides initSelection to use the local collection', function() { + var inputViewSpy = sinon.spy(OC.SystemTags, 'SystemTagsInputField'); + var element = $(''); + view.remove(); + view = new OCA.SystemTags.SystemTagsInfoView(); + view.selectedTagsCollection.add([ + {id: '1', name: 'test1'}, + {id: '3', name: 'test3'} + ]); + + var callback = sinon.stub(); + inputViewSpy.getCall(0).args[0].initSelection(element, callback); + + expect(callback.calledOnce).toEqual(true); + expect(callback.getCall(0).args[0]).toEqual([{ + id: '1', name: 'test1', userVisible: true, userAssignable: true + }, { + id: '3', name: 'test3', userVisible: true, userAssignable: true + }]); + + inputViewSpy.restore(); + }); + }); + describe('events', function() { + var allTagsCollection; + beforeEach(function() { + allTagsCollection = view._inputView.collection; + + allTagsCollection.add([ + {id: '1', name: 'test1'}, + {id: '2', name: 'test2'}, + {id: '3', name: 'test3'} + ]); + + view.selectedTagsCollection.add([ + {id: '1', name: 'test1'}, + {id: '3', name: 'test3'} + ]); + view.render(); + }); + + it('renames model in selection collection on rename', function() { + allTagsCollection.get('3').set('name', 'test3_renamed'); + + expect(view.selectedTagsCollection.get('3').get('name')).toEqual('test3_renamed'); + }); + + it('adds tag to selection collection when selected by input', function() { + var createStub = sinon.stub(OC.SystemTags.SystemTagsMappingCollection.prototype, 'create'); + view._inputView.trigger('select', allTagsCollection.get('2')); + + expect(createStub.calledOnce).toEqual(true); + expect(createStub.getCall(0).args[0]).toEqual({ + id: '2', + name: 'test2', + userVisible: true, + userAssignable: true + }); + + createStub.restore(); + }); + it('removes tag from selection collection when deselected by input', function() { + var destroyStub = sinon.stub(OC.SystemTags.SystemTagModel.prototype, 'destroy'); + view._inputView.trigger('deselect', '3'); + + expect(destroyStub.calledOnce).toEqual(true); + expect(destroyStub.calledOn(view.selectedTagsCollection.get('3'))).toEqual(true); + + destroyStub.restore(); + }); + + it('removes tag from selection whenever the tag was deleted globally', function() { + expect(view.selectedTagsCollection.get('3')).not.toBeFalsy(); + + allTagsCollection.remove('3'); + + expect(view.selectedTagsCollection.get('3')).toBeFalsy(); + + }); + }); +}); diff --git a/core/css/systemtags.css b/core/css/systemtags.css new file mode 100644 index 0000000000..5c667e5454 --- /dev/null +++ b/core/css/systemtags.css @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2016 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ +.systemtags-select2-dropdown .select2-selected { + display: list-item; + background-color: #f8f8f8; +} +.systemtags-select2-dropdown .select2-highlighted, +.systemtags-select2-dropdown .select2-selected.select2-highlighted { + background: #3875d7; +} + +.systemtags-select2-dropdown .select2-highlighted { + color: #000000; +} +.systemtags-select2-dropdown .select2-result-label .checkmark { + visibility: hidden; +} + +.systemtags-select2-dropdown .select2-result-label .new-item .systemtags-actions { + display: none; +} + +.systemtags-select2-dropdown .select2-selected .select2-result-label .checkmark { + visibility: visible; +} + +.systemtags-select2-dropdown .select2-result-label .icon { + display: inline-block; +} + +.systemtags-select2-dropdown .systemtags-actions { + float: right; +} + +.systemtags-select2-dropdown .systemtags-rename-form { + display: inline; + margin-left: 10px; +} + +.systemtags-select2-container { + width: 80%; +} + +.systemtags-select2-container .select2-choices { + white-space: nowrap; + text-overflow: ellipsis; + background: #fff; + color: #555; + box-sizing: content-box; + border-radius: 3px; + border: 1px solid #ddd; + margin: 3px 3px 3px 0; + padding: 7px 6px 5px; + min-height: auto; +} + +.systemtags-select2-container .select2-choices .select2-search-choice { + border: 0; + box-shadow: none; + background: none; + padding: 0; + margin: 0; + line-height: 20px; +} +.systemtags-select2-container .select2-choices .select2-search-choice-close { + display: none; +} +.systemtags-select2-container .select2-choices .select2-search-field input { + margin: 0; + padding: 0; + line-height: 20px; +} + diff --git a/core/js/core.json b/core/js/core.json index 43cb1b472f..93c64afea9 100644 --- a/core/js/core.json +++ b/core/js/core.json @@ -44,6 +44,10 @@ "mimetype.js", "mimetypelist.js", "files/fileinfo.js", - "files/client.js" + "files/client.js", + "systemtags/systemtagmodel.js", + "systemtags/systemtagscollection.js", + "systemtags/systemtagsmappingcollection.js", + "systemtags/systemtagsinputfield.js" ] } diff --git a/core/js/oc-backbone-webdav.js b/core/js/oc-backbone-webdav.js index 3ca31902c9..24a2bb5019 100644 --- a/core/js/oc-backbone-webdav.js +++ b/core/js/oc-backbone-webdav.js @@ -167,7 +167,7 @@ } function callPropPatch(client, options, model, headers) { - client.propPatch( + return client.propPatch( options.url, convertModelAttributesToDavProperties(model.changed, options.davProperties), headers diff --git a/core/js/select2-toggleselect.js b/core/js/select2-toggleselect.js index d38d4cc60b..4fada592a3 100644 --- a/core/js/select2-toggleselect.js +++ b/core/js/select2-toggleselect.js @@ -12,8 +12,6 @@ /** * Select2 extension for toggling values in a multi-select dropdown - * - * Inspired by http://stackoverflow.com/a/27466159 and adjusted */ (function(Select2) { @@ -28,28 +26,25 @@ var Select2TriggerSelect = Select2.class.multi.prototype.triggerSelect; Select2.class.multi.prototype.triggerSelect = function (data) { if (this.opts.toggleSelect && this.val().indexOf(this.id(data)) !== -1) { - - var val = this.id(data); - var evt = $.Event('select2-removing'); - evt.val = val; - evt.choice = data; - this.opts.element.trigger(evt); - - if (evt.isDefaultPrevented()) { - return false; - } - var self = this; - this.results.find('.select2-result.select2-selected').each(function () { - var $this = $(this); - if (self.id($this.data('select2-data')) === val) { - $this.removeClass('select2-selected'); - } + var val = this.id(data); + + var selectionEls = this.container.find('.select2-search-choice').filter(function() { + return (self.id($(this).data('select2-data')) === val); }); - this.opts.element.trigger({ type: "select2-removed", val: this.id(data), choice: data }); - this.triggerChange({ removed: data }); + if (this.unselect(selectionEls)) { + // also unselect in dropdown + this.results.find('.select2-result.select2-selected').each(function () { + var $this = $(this); + if (self.id($this.data('select2-data')) === val) { + $this.removeClass('select2-selected'); + } + }); + this.clearSearch(); + } + return false; } else { return Select2TriggerSelect.apply(this, arguments); } diff --git a/core/js/systemtags/systemtagmodel.js b/core/js/systemtags/systemtagmodel.js new file mode 100644 index 0000000000..ad6ea7b7d4 --- /dev/null +++ b/core/js/systemtags/systemtagmodel.js @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2015 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function(OC) { + var NS_OWNCLOUD = 'http://owncloud.org/ns'; + /** + * @class OCA.SystemTags.SystemTagsCollection + * @classdesc + * + * System tag + * + */ + var SystemTagModel = OC.Backbone.Model.extend( + /** @lends OCA.SystemTags.SystemTagModel.prototype */ { + sync: OC.Backbone.davSync, + + defaults: { + userVisible: true, + userAssignable: true + }, + + davProperties: { + 'id': '{' + NS_OWNCLOUD + '}id', + 'name': '{' + NS_OWNCLOUD + '}display-name', + 'userVisible': '{' + NS_OWNCLOUD + '}user-visible', + 'userAssignable': '{' + NS_OWNCLOUD + '}user-assignable' + }, + + parse: function(data) { + return { + id: data.id, + name: data.name, + userVisible: data.userVisible === '1', + userAssignable: data.userAssignable === '1' + }; + } + }); + + /** + * @namespace + */ + OC.SystemTags = OC.SystemTags || {}; + OC.SystemTags.SystemTagModel = SystemTagModel; +})(OC); + diff --git a/core/js/systemtags/systemtagscollection.js b/core/js/systemtags/systemtagscollection.js new file mode 100644 index 0000000000..0f8a7aa980 --- /dev/null +++ b/core/js/systemtags/systemtagscollection.js @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2015 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function(OC) { + + function filterFunction(model, term) { + return model.get('name').substr(0, term.length) === term; + } + + /** + * @class OCA.SystemTags.SystemTagsCollection + * @classdesc + * + * Collection of tags assigned to a file + * + */ + var SystemTagsCollection = OC.Backbone.Collection.extend( + /** @lends OC.SystemTags.SystemTagsCollection.prototype */ { + + sync: OC.Backbone.davSync, + + model: OC.SystemTags.SystemTagModel, + + url: function() { + return OC.linkToRemote('dav') + '/systemtags/'; + }, + + filterByName: function(name) { + return this.filter(function(model) { + return filterFunction(model, name); + }); + }, + + reset: function() { + this.fetched = false; + return OC.Backbone.Collection.prototype.reset.apply(this, arguments); + }, + + /** + * Lazy fetch. + * Only fetches once, subsequent calls will directly call the success handler. + * + * @param options + * @param [options.force] true to force fetch even if cached entries exist + * + * @see Backbone.Collection#fetch + */ + fetch: function(options) { + var self = this; + options = options || {}; + if (this.fetched || options.force) { + // directly call handler + if (options.success) { + options.success(this, null, options); + } + // trigger sync event + this.trigger('sync', this, null, options); + return Promise.resolve(); + } + + var success = options.success; + options = _.extend({}, options); + options.success = function() { + self.fetched = true; + if (success) { + return success.apply(this, arguments); + } + }; + + return OC.Backbone.Collection.prototype.fetch.call(this, options); + } + }); + + OC.SystemTags = OC.SystemTags || {}; + OC.SystemTags.SystemTagsCollection = SystemTagsCollection; + + /** + * @type OC.SystemTags.SystemTagsCollection + */ + OC.SystemTags.collection = new OC.SystemTags.SystemTagsCollection(); +})(OC); + diff --git a/core/js/systemtags/systemtagsinputfield.js b/core/js/systemtags/systemtagsinputfield.js new file mode 100644 index 0000000000..facacc50e2 --- /dev/null +++ b/core/js/systemtags/systemtagsinputfield.js @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2015 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +/* global Handlebars */ + +(function(OC) { + var TEMPLATE = + ''; + + var RESULT_TEMPLATE = + '' + + ' ' + + ' {{name}}' + + '{{#allowActions}}' + + ' ' + + ' ' + + ' ' + + '{{/allowActions}}' + + ''; + + var RENAME_FORM_TEMPLATE = + '
' + + ' ' + + ' ' + + ' ' + + '
'; + + /** + * @class OC.SystemTags.SystemTagsInputField + * @classdesc + * + * Displays a file's system tags + * + */ + var SystemTagsInputField = OC.Backbone.View.extend( + /** @lends OC.SystemTags.SystemTagsInputField.prototype */ { + + _rendered: false, + + _newTag: null, + + className: 'systemTagsInputFieldContainer', + + template: function(data) { + if (!this._template) { + this._template = Handlebars.compile(TEMPLATE); + } + return this._template(data); + }, + + /** + * Creates a new SystemTagsInputField + * + * @param {Object} [options] + * @param {string} [options.objectType=files] object type for which tags are assigned to + * @param {bool} [options.multiple=false] whether to allow selecting multiple tags + * @param {bool} [options.allowActions=true] whether tags can be renamed/delete within the dropdown + * @param {bool} [options.allowCreate=true] whether new tags can be created + * @param {Function} options.initSelection function to convert selection to data + */ + initialize: function(options) { + options = options || {}; + + this._multiple = !!options.multiple; + this._allowActions = _.isUndefined(options.allowActions) || !!options.allowActions; + this._allowCreate = _.isUndefined(options.allowCreate) || !!options.allowCreate; + + if (_.isFunction(options.initSelection)) { + this._initSelection = options.initSelection; + } + + this.collection = options.collection || OC.SystemTags.collection; + + var self = this; + this.collection.on('change:name remove', function() { + // refresh selection + _.defer(self._refreshSelection); + }); + + _.bindAll( + this, + '_refreshSelection', + '_onClickRenameTag', + '_onClickDeleteTag', + '_onSelectTag', + '_onDeselectTag', + '_onSubmitRenameTag' + ); + }, + + /** + * Refreshes the selection, triggering a call to + * select2's initSelection + */ + _refreshSelection: function() { + this.$tagsField.select2('val', this.$tagsField.val()); + }, + + /** + * Event handler whenever the user clicked the "rename" action. + * This will display the rename field. + */ + _onClickRenameTag: function(ev) { + var $item = $(ev.target).closest('.systemtags-item'); + var tagId = $item.attr('data-id'); + var tagModel = this.collection.get(tagId); + if (!this._renameFormTemplate) { + this._renameFormTemplate = Handlebars.compile(RENAME_FORM_TEMPLATE); + } + + var oldName = tagModel.get('name'); + var $renameForm = $(this._renameFormTemplate({ + cid: this.cid, + name: oldName, + deleteTooltip: t('core', 'Delete'), + renameLabel: t('core', 'Rename'), + })); + $item.find('.label').after($renameForm); + $item.find('.label, .systemtags-actions').addClass('hidden'); + $item.closest('.select2-result').addClass('has-form'); + + $renameForm.find('[title]').tooltip({ + placement: 'bottom', + container: 'body' + }); + $renameForm.find('input').focus().selectRange(0, oldName.length); + return false; + }, + + /** + * Event handler whenever the rename form has been submitted after + * the user entered a new tag name. + * This will submit the change to the server. + * + * @param {Object} ev event + */ + _onSubmitRenameTag: function(ev) { + ev.preventDefault(); + var $form = $(ev.target); + var $item = $form.closest('.systemtags-item'); + var tagId = $item.attr('data-id'); + var tagModel = this.collection.get(tagId); + var newName = $(ev.target).find('input').val(); + if (newName && newName !== tagModel.get('name')) { + tagModel.save({'name': newName}); + // TODO: spinner, and only change text after finished saving + $item.find('.label').text(newName); + } + $item.find('.label, .systemtags-actions').removeClass('hidden'); + $form.remove(); + $item.closest('.select2-result').removeClass('has-form'); + }, + + /** + * Event handler whenever a tag must be deleted + * + * @param {Object} ev event + */ + _onClickDeleteTag: function(ev) { + var $item = $(ev.target).closest('.systemtags-item'); + var tagId = $item.attr('data-id'); + this.collection.get(tagId).destroy(); + $item.closest('.select2-result').remove(); + // TODO: spinner + return false; + }, + + /** + * Event handler whenever a tag is selected. + * Also called whenever tag creation is requested through the dummy tag object. + * + * @param {Object} e event + */ + _onSelectTag: function(e) { + var self = this; + var tag; + if (e.object && e.object.isNew) { + // newly created tag, check if existing + // create a new tag + tag = this.collection.create({ + name: e.object.name, + userVisible: true, + userAssignable: true + }, { + success: function(model) { + var data = self.$tagsField.select2('data'); + data.push(model.toJSON()); + self.$tagsField.select2('data', data); + self.trigger('select', model); + } + }); + this.$tagsField.select2('close'); + e.preventDefault(); + return false; + } else { + tag = this.collection.get(e.object.id); + } + this._newTag = null; + this.trigger('select', tag); + }, + + /** + * Event handler whenever a tag gets deselected. + * + * @param {Object} e event + */ + _onDeselectTag: function(e) { + this.trigger('deselect', e.choice.id); + }, + + /** + * Autocomplete function for dropdown results + * + * @param {Object} query select2 query object + */ + _queryTagsAutocomplete: function(query) { + var self = this; + this.collection.fetch({ + success: function() { + query.callback({ + results: _.invoke(self.collection.filterByName(query.term), 'toJSON') + }); + } + }); + }, + + _preventDefault: function(e) { + e.stopPropagation(); + }, + + /** + * Formats a single dropdown result + * + * @param {Object} data data to format + * @return {string} HTML markup + */ + _formatDropDownResult: function(data) { + if (!this._resultTemplate) { + this._resultTemplate = Handlebars.compile(RESULT_TEMPLATE); + } + return this._resultTemplate(_.extend({ + renameTooltip: t('core', 'Rename'), + allowActions: this._allowActions + }, data)); + }, + + /** + * Create new dummy choice for select2 when the user + * types an arbitrary string + * + * @param {string} term entered term + * @return {Object} dummy tag + */ + _createSearchChoice: function(term) { + if (this.collection.filterByName(term).length) { + return; + } + if (!this._newTag) { + this._newTag = { + id: -1, + name: term, + isNew: true + }; + } else { + this._newTag.name = term; + } + + return this._newTag; + }, + + _initSelection: function(element, callback) { + var self = this; + var ids = $(element).val().split(','); + + function findSelectedObjects(ids) { + var selectedModels = self.collection.filter(function(model) { + return ids.indexOf(model.id) >= 0; + }); + return _.invoke(selectedModels, 'toJSON'); + } + + this.collection.fetch({ + success: function() { + callback(findSelectedObjects(ids)); + } + }); + }, + + /** + * Renders this details view + */ + render: function() { + var self = this; + this.$el.html(this.template()); + + this.$el.find('[title]').tooltip({placement: 'bottom'}); + this.$tagsField = this.$el.find('[name=tags]'); + this.$tagsField.select2({ + placeholder: t('core', 'Global tags'), + containerCssClass: 'systemtags-select2-container', + dropdownCssClass: 'systemtags-select2-dropdown', + closeOnSelect: false, + allowClear: false, + multiple: this._multiple, + toggleSelect: this._multiple, + query: _.bind(this._queryTagsAutocomplete, this), + id: function(tag) { + return tag.id; + }, + initSelection: _.bind(this._initSelection, this), + formatResult: _.bind(this._formatDropDownResult, this), + formatSelection: function(tag) { + return '' + escapeHTML(tag.name) + '' + + ''; + }, + createSearchChoice: this._allowCreate ? _.bind(this._createSearchChoice, this) : undefined, + sortResults: function(results) { + var selectedItems = _.pluck(self.$tagsField.select2('data'), 'id'); + results.sort(function(a, b) { + var aSelected = selectedItems.indexOf(a.id) >= 0; + var bSelected = selectedItems.indexOf(b.id) >= 0; + if (aSelected === bSelected) { + return OC.Util.naturalSortCompare(a.name, b.name); + } + if (aSelected && !bSelected) { + return -1; + } + return 1; + }); + return results; + } + }) + .on('select2-selecting', this._onSelectTag) + .on('select2-removing', this._onDeselectTag); + + var $dropDown = this.$tagsField.select2('dropdown'); + // register events for inside the dropdown + $dropDown.on('mouseup', '.rename', this._onClickRenameTag); + $dropDown.on('mouseup', '.delete', this._onClickDeleteTag); + $dropDown.on('mouseup', '.select2-result-selectable.has-form', this._preventDefault); + $dropDown.on('submit', '.systemtags-rename-form', this._onSubmitRenameTag); + + this.delegateEvents(); + }, + + remove: function() { + if (this.$tagsField) { + this.$tagsField.select2('destroy'); + } + }, + + setValues: function(values) { + this.$tagsField.select2('val', values); + }, + + setData: function(data) { + this.$tagsField.select2('data', data); + } + }); + + OC.SystemTags = OC.SystemTags || {}; + OC.SystemTags.SystemTagsInputField = SystemTagsInputField; + +})(OC); + diff --git a/core/js/systemtags/systemtagsmappingcollection.js b/core/js/systemtags/systemtagsmappingcollection.js new file mode 100644 index 0000000000..f30a9dbd98 --- /dev/null +++ b/core/js/systemtags/systemtagsmappingcollection.js @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2015 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +(function(OC) { + /** + * @class OC.SystemTags.SystemTagsMappingCollection + * @classdesc + * + * Collection of tags assigned to a an object + * + */ + var SystemTagsMappingCollection = OC.Backbone.Collection.extend( + /** @lends OC.SystemTags.SystemTagsMappingCollection.prototype */ { + + sync: OC.Backbone.davSync, + + /** + * Use PUT instead of PROPPATCH + */ + usePUT: true, + + /** + * Id of the file for which to filter activities by + * + * @var int + */ + _objectId: null, + + /** + * Type of the object to filter by + * + * @var string + */ + _objectType: 'files', + + model: OC.SystemTags.SystemTagModel, + + url: function() { + return OC.linkToRemote('dav') + '/systemtags-relations/' + this._objectType + '/' + this._objectId; + }, + + /** + * Sets the object id to filter by or null for all. + * + * @param {int} objectId file id or null + */ + setObjectId: function(objectId) { + this._objectId = objectId; + }, + + /** + * Sets the object type to filter by or null for all. + * + * @param {int} objectType file id or null + */ + setObjectType: function(objectType) { + this._objectType = objectType; + }, + + initialize: function(models, options) { + options = options || {}; + if (!_.isUndefined(options.objectId)) { + this._objectId = options.objectId; + } + if (!_.isUndefined(options.objectType)) { + this._objectType = options.objectType; + } + }, + + getTagIds: function() { + return this.map(function(model) { + return model.id; + }); + } + }); + + OC.SystemTags = OC.SystemTags || {}; + OC.SystemTags.SystemTagsMappingCollection = SystemTagsMappingCollection; +})(OC); + diff --git a/core/js/tests/specHelper.js b/core/js/tests/specHelper.js index f09a7054c9..d1c7873f0e 100644 --- a/core/js/tests/specHelper.js +++ b/core/js/tests/specHelper.js @@ -160,7 +160,7 @@ window.isPhantom = /phantom/i.test(navigator.userAgent); OC.Plugins._plugins = []; // dummy select2 (which isn't loaded during the tests) - $.fn.select2 = function() {}; + $.fn.select2 = function() { return this; }; }); afterEach(function() { diff --git a/core/js/tests/specs/systemtags/systemtagscollectionSpec.js b/core/js/tests/specs/systemtags/systemtagscollectionSpec.js new file mode 100644 index 0000000000..6f2d836175 --- /dev/null +++ b/core/js/tests/specs/systemtags/systemtagscollectionSpec.js @@ -0,0 +1,84 @@ +/** +* ownCloud +* +* @author Vincent Petry +* @copyright 2016 Vincent Petry +* +* This library is free software; you can redistribute it and/or +* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +* License as published by the Free Software Foundation; either +* version 3 of the License, or any later version. +* +* This library is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU AFFERO GENERAL PUBLIC LICENSE for more details. +* +* You should have received a copy of the GNU Affero General Public +* License along with this library. If not, see . +* +*/ + +describe('OC.SystemTags.SystemTagsCollection tests', function() { + var collection; + + beforeEach(function() { + collection = new OC.SystemTags.SystemTagsCollection(); + }); + it('fetches only once, until reset', function() { + var syncStub = sinon.stub(collection, 'sync'); + var callback = sinon.stub(); + var callback2 = sinon.stub(); + var callback3 = sinon.stub(); + var eventHandler = sinon.stub(); + + collection.on('sync', eventHandler); + + collection.fetch({ + success: callback + }); + + expect(callback.notCalled).toEqual(true); + expect(syncStub.calledOnce).toEqual(true); + expect(eventHandler.notCalled).toEqual(true); + + syncStub.yieldTo('success', collection); + + expect(callback.calledOnce).toEqual(true); + expect(callback.firstCall.args[0]).toEqual(collection); + expect(eventHandler.calledOnce).toEqual(true); + expect(eventHandler.firstCall.args[0]).toEqual(collection); + + collection.fetch({ + success: callback2 + }); + + expect(eventHandler.calledTwice).toEqual(true); + expect(eventHandler.secondCall.args[0]).toEqual(collection); + + // not re-called + expect(syncStub.calledOnce).toEqual(true); + + expect(callback.calledOnce).toEqual(true); + expect(callback2.calledOnce).toEqual(true); + expect(callback2.firstCall.args[0]).toEqual(collection); + + expect(collection.fetched).toEqual(true); + + collection.reset(); + + expect(collection.fetched).toEqual(false); + + collection.fetch({ + success: callback3 + }); + + expect(syncStub.calledTwice).toEqual(true); + + syncStub.yieldTo('success', collection); + expect(callback3.calledOnce).toEqual(true); + expect(callback3.firstCall.args[0]).toEqual(collection); + + syncStub.restore(); + }); +}); diff --git a/core/js/tests/specs/systemtags/systemtagsinputfieldSpec.js b/core/js/tests/specs/systemtags/systemtagsinputfieldSpec.js new file mode 100644 index 0000000000..dc8d2ec82f --- /dev/null +++ b/core/js/tests/specs/systemtags/systemtagsinputfieldSpec.js @@ -0,0 +1,308 @@ +/** +* ownCloud +* +* @author Vincent Petry +* @copyright 2016 Vincent Petry +* +* This library is free software; you can redistribute it and/or +* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE +* License as published by the Free Software Foundation; either +* version 3 of the License, or any later version. +* +* This library is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU AFFERO GENERAL PUBLIC LICENSE for more details. +* +* You should have received a copy of the GNU Affero General Public +* License along with this library. If not, see . +* +*/ + +describe('OC.SystemTags.SystemTagsInputField tests', function() { + var view, select2Stub; + + beforeEach(function() { + var $container = $('
'); + select2Stub = sinon.stub($.fn, 'select2'); + select2Stub.returnsThis(); + $('#testArea').append($container); + view = new OC.SystemTags.SystemTagsInputField(); + $container.append(view.$el); + }); + afterEach(function() { + select2Stub.restore(); + OC.SystemTags.collection.reset(); + view.remove(); + view = undefined; + }); + describe('rendering', function() { + beforeEach(function() { + view.render(); + }); + it('calls select2 on rendering', function() { + expect(view.$el.find('input[name=tags]').length).toEqual(1); + expect(select2Stub.called).toEqual(true); + }); + it('formatResult renders rename button', function() { + var opts = select2Stub.getCall(0).args[0]; + var $el = $(opts.formatResult({id: '1', name: 'test'})); + expect($el.find('.label').text()).toEqual('test'); + expect($el.find('.rename').length).toEqual(1); + }); + }); + describe('initSelection', function() { + var fetchStub; + var testTags; + + beforeEach(function() { + fetchStub = sinon.stub(OC.SystemTags.SystemTagsCollection.prototype, 'fetch'); + testTags = [ + new OC.SystemTags.SystemTagModel({id: '1', name: 'test1'}), + new OC.SystemTags.SystemTagModel({id: '2', name: 'test2'}), + new OC.SystemTags.SystemTagModel({id: '3', name: 'test3'}), + ]; + view.render(); + }); + afterEach(function() { + fetchStub.restore(); + }); + it('grabs values from the full collection', function() { + var $el = view.$el.find('input'); + $el.val('1,3'); + var opts = select2Stub.getCall(0).args[0]; + var callback = sinon.stub(); + opts.initSelection($el, callback); + + expect(fetchStub.calledOnce).toEqual(true); + view.collection.add(testTags); + fetchStub.yieldTo('success', view.collection); + + expect(callback.calledOnce).toEqual(true); + var models = callback.getCall(0).args[0]; + expect(models.length).toEqual(2); + expect(models[0].id).toEqual('1'); + expect(models[0].name).toEqual('test1'); + expect(models[1].id).toEqual('3'); + expect(models[1].name).toEqual('test3'); + }); + }); + describe('tag selection', function() { + beforeEach(function() { + view.render(); + var $el = view.$el.find('input'); + $el.val('1'); + + view.collection.add([ + new OC.SystemTags.SystemTagModel({id: '1', name: 'abc'}), + new OC.SystemTags.SystemTagModel({id: '2', name: 'def'}), + new OC.SystemTags.SystemTagModel({id: '3', name: 'abd'}), + ]); + }); + afterEach(function() { + }); + it('does not create dummy tag when user types non-matching name', function() { + var opts = select2Stub.getCall(0).args[0]; + var result = opts.createSearchChoice('abc'); + expect(result).not.toBeDefined(); + }); + it('creates dummy tag when user types non-matching name', function() { + var opts = select2Stub.getCall(0).args[0]; + var result = opts.createSearchChoice('abnew'); + expect(result.id).toEqual(-1); + expect(result.name).toEqual('abnew'); + expect(result.isNew).toEqual(true); + }); + it('creates the real tag and fires select event after user selects the dummy tag', function() { + var selectHandler = sinon.stub(); + view.on('select', selectHandler); + var createStub = sinon.stub(OC.SystemTags.SystemTagsCollection.prototype, 'create'); + view.$el.find('input').trigger(new $.Event('select2-selecting', { + object: { + id: -1, + name: 'newname', + isNew: true + } + })); + + expect(createStub.calledOnce).toEqual(true); + expect(createStub.getCall(0).args[0]).toEqual({ + name: 'newname', + userVisible: true, + userAssignable: true + }); + + var newModel = new OC.SystemTags.SystemTagModel({ + id: '123', + name: 'newname', + userVisible: true, + userAssignable: true + }); + + // not called yet + expect(selectHandler.notCalled).toEqual(true); + + select2Stub.withArgs('data').returns([{ + id: '1', + name: 'abc' + }]); + + createStub.yieldTo('success', newModel); + + expect(select2Stub.lastCall.args[0]).toEqual('data'); + expect(select2Stub.lastCall.args[1]).toEqual([{ + id: '1', + name: 'abc' + }, + newModel.toJSON() + ]); + + expect(selectHandler.calledOnce).toEqual(true); + expect(selectHandler.getCall(0).args[0]).toEqual(newModel); + + createStub.restore(); + }); + it('triggers select event after selecting an existing tag', function() { + var selectHandler = sinon.stub(); + view.on('select', selectHandler); + view.$el.find('input').trigger(new $.Event('select2-selecting', { + object: { + id: '2', + name: 'def' + } + })); + + expect(selectHandler.calledOnce).toEqual(true); + expect(selectHandler.getCall(0).args[0]).toEqual(view.collection.get('2')); + }); + it('triggers deselect event after deselecting an existing tag', function() { + var selectHandler = sinon.stub(); + view.on('deselect', selectHandler); + view.$el.find('input').trigger(new $.Event('select2-removing', { + choice: { + id: '2', + name: 'def' + } + })); + + expect(selectHandler.calledOnce).toEqual(true); + expect(selectHandler.getCall(0).args[0]).toEqual('2'); + }); + }); + describe('autocomplete', function() { + var fetchStub, opts; + + beforeEach(function() { + fetchStub = sinon.stub(OC.SystemTags.SystemTagsCollection.prototype, 'fetch'); + view.render(); + opts = select2Stub.getCall(0).args[0]; + + view.collection.add([ + new OC.SystemTags.SystemTagModel({id: '1', name: 'abc'}), + new OC.SystemTags.SystemTagModel({id: '2', name: 'def'}), + new OC.SystemTags.SystemTagModel({id: '3', name: 'abd'}), + ]); + }); + afterEach(function() { + fetchStub.restore(); + }); + it('completes results', function() { + var callback = sinon.stub(); + opts.query({ + term: 'ab', + callback: callback + }); + expect(fetchStub.calledOnce).toEqual(true); + + fetchStub.yieldTo('success', view.collection); + + expect(callback.calledOnce).toEqual(true); + expect(callback.getCall(0).args[0].results).toEqual([ + { + id: '1', + name: 'abc', + userVisible: true, + userAssignable: true + }, + { + id: '3', + name: 'abd', + userVisible: true, + userAssignable: true + } + ]); + }); + }); + describe('tag actions', function() { + var $dropdown, opts; + + beforeEach(function() { + $dropdown = $('
'); + select2Stub.withArgs('dropdown').returns($dropdown); + $('#testArea').append($dropdown); + + view.render(); + + opts = select2Stub.getCall(0).args[0]; + + view.collection.add([ + new OC.SystemTags.SystemTagModel({id: '1', name: 'abc'}), + ]); + + $dropdown.append(opts.formatResult(view.collection.get('1').toJSON())); + + }); + afterEach(function() { + }); + it('displays rename form when clicking rename', function() { + $dropdown.find('.rename').mouseup(); + expect($dropdown.find('form.systemtags-rename-form').length).toEqual(1); + expect($dropdown.find('form.systemtags-rename-form input').val()).toEqual('abc'); + }); + it('renames model and submits change when submitting form', function() { + var saveStub = sinon.stub(OC.SystemTags.SystemTagModel.prototype, 'save'); + $dropdown.find('.rename').mouseup(); + $dropdown.find('form input').val('abc_renamed'); + $dropdown.find('form').trigger(new $.Event('submit')); + + expect(saveStub.calledOnce).toEqual(true); + expect(saveStub.getCall(0).args[0]).toEqual({'name': 'abc_renamed'}); + + expect($dropdown.find('.label').text()).toEqual('abc_renamed'); + expect($dropdown.find('form').length).toEqual(0); + + saveStub.restore(); + }); + it('deletes model and submits change when clicking delete', function() { + var destroyStub = sinon.stub(OC.SystemTags.SystemTagModel.prototype, 'destroy'); + + expect($dropdown.find('.delete').length).toEqual(0); + $dropdown.find('.rename').mouseup(); + // delete button appears + expect($dropdown.find('.delete').length).toEqual(1); + $dropdown.find('.delete').mouseup(); + + expect(destroyStub.calledOnce).toEqual(true); + expect(destroyStub.calledOn(view.collection.get('1'))); + + destroyStub.restore(); + }); + }); + describe('setting data', function() { + beforeEach(function() { + view.render(); + }); + it('sets value when calling setValues', function() { + var vals = ['1', '2']; + view.setValues(vals); + expect(select2Stub.lastCall.args[0]).toEqual('val'); + expect(select2Stub.lastCall.args[1]).toEqual(vals); + }); + it('sets data when calling setData', function() { + var vals = [{id: '1', name: 'test1'}, {id: '2', name: 'test2'}]; + view.setData(vals); + expect(select2Stub.lastCall.args[0]).toEqual('data'); + expect(select2Stub.lastCall.args[1]).toEqual(vals); + }); + }); +}); diff --git a/core/shipped.json b/core/shipped.json index 28e99c4feb..5dd8700bf1 100644 --- a/core/shipped.json +++ b/core/shipped.json @@ -28,6 +28,7 @@ "password_policy", "provisioning_api", "sharepoint", + "systemtags", "templateeditor", "updater", "user_external", diff --git a/tests/karma.config.js b/tests/karma.config.js index df09ee1b31..467b270b35 100644 --- a/tests/karma.config.js +++ b/tests/karma.config.js @@ -82,6 +82,16 @@ module.exports = function(config) { ], testFiles: ['apps/files_versions/tests/js/**/*.js'] }, + { + name: 'systemtags', + srcFiles: [ + // need to enforce loading order... + 'apps/systemtags/js/app.js', + 'apps/systemtags/js/systemtagsinfoview.js', + 'apps/systemtags/js/filesplugin.js' + ], + testFiles: ['apps/systemtags/tests/js/**/*.js'] + }, { name: 'settings', srcFiles: [