1
0
Эх сурвалжийг харах

Added system tags GUI in sidebar

Added files details sidebar panel to assign/unassign/rename/delete
system tags.
Vincent Petry 8 жил өмнө
parent
commit
ffba6d0a7e

+ 2 - 1
.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

+ 1 - 1
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);

+ 2 - 2
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);
 	}

+ 40 - 0
apps/systemtags/appinfo/app.php

@@ -0,0 +1,40 @@
+<?php
+/**
+ * @author Vincent Petry <pvince81@owncloud.com>
+ *
+ * @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 <http://www.gnu.org/licenses/>
+ *
+ */
+
+$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');
+	}
+);

+ 18 - 0
apps/systemtags/appinfo/info.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0"?>
+<info>
+	<id>systemtags</id>
+	<name>System tags</name>
+	<description>System-wide tags user interface</description>
+	<licence>AGPL</licence>
+	<author>Vincent Petry</author>
+	<shipped>true</shipped>
+	<standalone/>
+	<default_enable/>
+	<version>0.1</version>
+	<dependencies>
+		<owncloud min-version="9.0" />
+	</dependencies>
+	<documentation>
+		<user>user-systemtags</user>
+	</documentation>
+</info>

+ 20 - 0
apps/systemtags/js/app.js

@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2015 Vincent Petry <pvince81@owncloud.com>
+ *
+ * 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 = {};
+	}
+
+})();
+

+ 41 - 0
apps/systemtags/js/filesplugin.js

@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2015 Vincent Petry <pvince81@owncloud.com>
+ *
+ * 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);
+

+ 135 - 0
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);
+

+ 149 - 0
apps/systemtags/tests/js/systemtagsinfoviewSpec.js

@@ -0,0 +1,149 @@
+/**
+* ownCloud
+*
+* @author Vincent Petry
+* @copyright 2016 Vincent Petry <pvince81@owncloud.com>
+*
+* 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 <http://www.gnu.org/licenses/>.
+*
+*/
+
+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 = $('<input type="hidden" val="1,3"/>');
+			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();
+
+		});
+	});
+});

+ 80 - 0
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;
+}
+

+ 5 - 1
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"
 	]
 }

+ 1 - 1
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

+ 14 - 19
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 self = this;
 			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 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);
 		}

+ 52 - 0
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);
+

+ 89 - 0
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);
+

+ 372 - 0
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 =
+		'<input class="systemTagsInputField" type="hidden" name="tags" value=""/>';
+
+	var RESULT_TEMPLATE =
+		'<span class="systemtags-item{{#if isNew}} new-item{{/if}}" data-id="{{id}}">' +
+		'    <span class="checkmark icon icon-checkmark"></span>' +
+		'    <span class="label">{{name}}</span>' +
+		'{{#allowActions}}' +
+		'    <span class="systemtags-actions">' +
+		'        <a href="#" class="rename icon icon-rename" title="{{renameTooltip}}"></a>' +
+		'    </span>' +
+		'{{/allowActions}}' +
+		'</span>';
+
+	var RENAME_FORM_TEMPLATE =
+		'<form class="systemtags-rename-form">' +
+		'    <label class="hidden-visually" for="{{cid}}-rename-input">{{renameLabel}}</label>' +
+		'    <input id="{{cid}}-rename-input" type="text" value="{{name}}">' +
+		'    <a href="#" class="delete icon icon-delete" title="{{deleteTooltip}}"></a>' +
+		'</form>';
+
+	/**
+	 * @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 '<span class="label">' + escapeHTML(tag.name) + '</span>' +
+						'<span class="comma">,&nbsp;</span>';
+				},
+				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);
+

+ 87 - 0
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);
+

+ 1 - 1
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() {

+ 84 - 0
core/js/tests/specs/systemtags/systemtagscollectionSpec.js

@@ -0,0 +1,84 @@
+/**
+* ownCloud
+*
+* @author Vincent Petry
+* @copyright 2016 Vincent Petry <pvince81@owncloud.com>
+*
+* 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 <http://www.gnu.org/licenses/>.
+*
+*/
+
+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();
+	});
+});

+ 308 - 0
core/js/tests/specs/systemtags/systemtagsinputfieldSpec.js

@@ -0,0 +1,308 @@
+/**
+* ownCloud
+*
+* @author Vincent Petry
+* @copyright 2016 Vincent Petry <pvince81@owncloud.com>
+*
+* 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 <http://www.gnu.org/licenses/>.
+*
+*/
+
+describe('OC.SystemTags.SystemTagsInputField tests', function() {
+	var view, select2Stub;
+
+	beforeEach(function() {
+		var $container = $('<div class="testInputContainer"></div>');
+		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 = $('<div class="select2-dropdown"></div>');
+			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);
+		});
+	});
+});

+ 1 - 0
core/shipped.json

@@ -28,6 +28,7 @@
     "password_policy",
     "provisioning_api",
     "sharepoint",
+    "systemtags",
     "templateeditor",
     "updater",
     "user_external",

+ 10 - 0
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: [