diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2019-01-28 17:00:56 +0100 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2019-01-29 08:40:19 +0100 |
commit | aa2ff51a122432d2aea376ca028179c5b5a70a33 (patch) | |
tree | 630d55417d09ac73a7ce7b262af135f722ebdd17 /core/src/OC | |
parent | 198a45ff75c64d38c1ccb6bd903ee55b446177b3 (diff) | |
download | nextcloud-server-aa2ff51a122432d2aea376ca028179c5b5a70a33.tar.gz nextcloud-server-aa2ff51a122432d2aea376ca028179c5b5a70a33.zip |
Move OC.Contactsmenu and OC.Backbone to the server bundle
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Diffstat (limited to 'core/src/OC')
-rw-r--r-- | core/src/OC/backbone-webdav.js | 357 | ||||
-rw-r--r-- | core/src/OC/contactsmenu.js | 480 | ||||
-rw-r--r-- | core/src/OC/index.js | 12 |
3 files changed, 849 insertions, 0 deletions
diff --git a/core/src/OC/backbone-webdav.js b/core/src/OC/backbone-webdav.js new file mode 100644 index 00000000000..f658532fa18 --- /dev/null +++ b/core/src/OC/backbone-webdav.js @@ -0,0 +1,357 @@ +/* + * Copyright (c) 2015 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +/** + * Webdav transport for Backbone. + * + * This makes it possible to use Webdav endpoints when + * working with Backbone models and collections. + * + * Requires the davclient.js library. + * + * Usage example: + * + * var PersonModel = OC.Backbone.Model.extend({ + * // make it use the DAV transport + * sync: OC.Backbone.davSync, + * + * // DAV properties mapping + * davProperties: { + * 'id': '{http://example.com/ns}id', + * 'firstName': '{http://example.com/ns}first-name', + * 'lastName': '{http://example.com/ns}last-name', + * 'age': '{http://example.com/ns}age' + * }, + * + * // additional parsing, if needed + * parse: function(props) { + * // additional parsing (DAV property values are always strings) + * props.age = parseInt(props.age, 10); + * return props; + * } + * }); + * + * var PersonCollection = OC.Backbone.Collection.extend({ + * // make it use the DAV transport + * sync: OC.Backbone.davSync, + * + * // use person model + * // note that davProperties will be inherited + * model: PersonModel, + * + * // DAV collection URL + * url: function() { + * return OC.linkToRemote('dav') + '/person/'; + * }, + * }); + */ + +import _ from 'underscore'; +import dav from 'davclient.js'; + +const methodMap = { + create: 'POST', + update: 'PROPPATCH', + patch: 'PROPPATCH', + delete: 'DELETE', + read: 'PROPFIND' +}; + +// Throw an error when a URL is needed, and none is supplied. +function urlError () { + throw new Error('A "url" property or function must be specified'); +} + +/** + * Convert a single propfind result to JSON + * + * @param {Object} result + * @param {Object} davProperties properties mapping + */ +function parsePropFindResult (result, davProperties) { + if (_.isArray(result)) { + return _.map(result, function (subResult) { + return parsePropFindResult(subResult, davProperties); + }); + } + var props = { + href: result.href + }; + + _.each(result.propStat, function (propStat) { + if (propStat.status !== 'HTTP/1.1 200 OK') { + return; + } + + for (var key in propStat.properties) { + var propKey = key; + if (key in davProperties) { + propKey = davProperties[key]; + } + props[propKey] = propStat.properties[key]; + } + }); + + if (!props.id) { + // parse id from href + props.id = parseIdFromLocation(props.href); + } + + return props; +} + +/** + * Parse ID from location + * + * @param {string} url url + * @return {string} id + */ +function parseIdFromLocation (url) { + var queryPos = url.indexOf('?'); + if (queryPos > 0) { + url = url.substr(0, queryPos); + } + + var parts = url.split('/'); + var result; + do { + result = parts[parts.length - 1]; + parts.pop(); + // note: first result can be empty when there is a trailing slash, + // so we take the part before that + } while (!result && parts.length > 0); + + return result; +} + +function isSuccessStatus (status) { + return status >= 200 && status <= 299; +} + +function convertModelAttributesToDavProperties (attrs, davProperties) { + var props = {}; + var key; + for (key in attrs) { + var changedProp = davProperties[key]; + var value = attrs[key]; + if (!changedProp) { + console.warn('No matching DAV property for property "' + key); + changedProp = key; + } + if (_.isBoolean(value) || _.isNumber(value)) { + // convert to string + value = '' + value; + } + props[changedProp] = value; + } + return props; +} + +function callPropFind (client, options, model, headers) { + return client.propFind( + options.url, + _.values(options.davProperties) || [], + options.depth, + headers + ).then(function (response) { + if (isSuccessStatus(response.status)) { + if (_.isFunction(options.success)) { + var propsMapping = _.invert(options.davProperties); + var results = parsePropFindResult(response.body, propsMapping); + if (options.depth > 0) { + // discard root entry + results.shift(); + } + + options.success(results); + return; + } + } else if (_.isFunction(options.error)) { + options.error(response); + } + }); +} + +function callPropPatch (client, options, model, headers) { + return client.propPatch( + options.url, + convertModelAttributesToDavProperties(model.changed, options.davProperties), + headers + ).then(function (result) { + if (isSuccessStatus(result.status)) { + if (_.isFunction(options.success)) { + // pass the object's own values because the server + // does not return the updated model + options.success(model.toJSON()); + } + } else if (_.isFunction(options.error)) { + options.error(result); + } + }); + +} + +function callMkCol (client, options, model, headers) { + // call MKCOL without data, followed by PROPPATCH + return client.request( + options.type, + options.url, + headers, + null + ).then(function (result) { + if (!isSuccessStatus(result.status)) { + if (_.isFunction(options.error)) { + options.error(result); + } + return; + } + + callPropPatch(client, options, model, headers); + }); +} + +function callMethod (client, options, model, headers) { + headers['Content-Type'] = 'application/json'; + return client.request( + options.type, + options.url, + headers, + options.data + ).then(function (result) { + if (!isSuccessStatus(result.status)) { + if (_.isFunction(options.error)) { + options.error(result); + } + return; + } + + if (_.isFunction(options.success)) { + if (options.type === 'PUT' || options.type === 'POST' || options.type === 'MKCOL') { + // pass the object's own values because the server + // does not return anything + var responseJson = result.body || model.toJSON(); + var locationHeader = result.xhr.getResponseHeader('Content-Location'); + if (options.type === 'POST' && locationHeader) { + responseJson.id = parseIdFromLocation(locationHeader); + } + options.success(responseJson); + return; + } + // if multi-status, parse + if (result.status === 207) { + var propsMapping = _.invert(options.davProperties); + options.success(parsePropFindResult(result.body, propsMapping)); + } else { + options.success(result.body); + } + } + }); +} + +export function davCall (options, model) { + var client = new dav.Client({ + baseUrl: options.url, + xmlNamespaces: _.extend({ + 'DAV:': 'd', + 'http://owncloud.org/ns': 'oc' + }, options.xmlNamespaces || {}) + }); + client.resolveUrl = function () { + return options.url; + }; + var headers = _.extend({ + 'X-Requested-With': 'XMLHttpRequest', + 'requesttoken': OC.requestToken + }, options.headers); + if (options.type === 'PROPFIND') { + return callPropFind(client, options, model, headers); + } else if (options.type === 'PROPPATCH') { + return callPropPatch(client, options, model, headers); + } else if (options.type === 'MKCOL') { + return callMkCol(client, options, model, headers); + } else { + return callMethod(client, options, model, headers); + } +} + +/** + * DAV transport + */ +export function davSync (method, model, options) { + var params = {type: methodMap[method] || method}; + var isCollection = (model instanceof Backbone.Collection); + + if (method === 'update') { + // if a model has an inner collection, it must define an + // attribute "hasInnerCollection" that evaluates to true + if (model.hasInnerCollection) { + // if the model itself is a Webdav collection, use MKCOL + params.type = 'MKCOL'; + } else if (model.usePUT || (model.collection && model.collection.usePUT)) { + // use PUT instead of PROPPATCH + params.type = 'PUT'; + } + } + + // Ensure that we have a URL. + if (!options.url) { + params.url = _.result(model, 'url') || urlError(); + } + + // Ensure that we have the appropriate request data. + if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) { + params.data = JSON.stringify(options.attrs || model.toJSON(options)); + } + + // Don't process data on a non-GET request. + if (params.type !== 'PROPFIND') { + params.processData = false; + } + + if (params.type === 'PROPFIND' || params.type === 'PROPPATCH') { + var davProperties = model.davProperties; + if (!davProperties && model.model) { + // use dav properties from model in case of collection + davProperties = model.model.prototype.davProperties; + } + if (davProperties) { + if (_.isFunction(davProperties)) { + params.davProperties = davProperties.call(model); + } else { + params.davProperties = davProperties; + } + } + + params.davProperties = _.extend(params.davProperties || {}, options.davProperties); + + if (_.isUndefined(options.depth)) { + if (isCollection) { + options.depth = 1; + } else { + options.depth = 0; + } + } + } + + // Pass along `textStatus` and `errorThrown` from jQuery. + var error = options.error; + options.error = function (xhr, textStatus, errorThrown) { + options.textStatus = textStatus; + options.errorThrown = errorThrown; + if (error) { + error.call(options.context, xhr, textStatus, errorThrown); + } + }; + + // Make the request, allowing the user to override any Ajax options. + var xhr = options.xhr = Backbone.davCall(_.extend(params, options), model); + model.trigger('request', model, xhr, options); + return xhr; +} diff --git a/core/src/OC/contactsmenu.js b/core/src/OC/contactsmenu.js new file mode 100644 index 00000000000..88218830337 --- /dev/null +++ b/core/src/OC/contactsmenu.js @@ -0,0 +1,480 @@ +/* global Backbone, Handlebars, Promise, _ */ + +/** + * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2017 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * 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 + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import $ from 'jquery'; +import {Collection, Model, View} from 'backbone'; + +import OC from './index'; + +/** + * @class Contact + */ +const Contact = Model.extend({ + defaults: { + fullName: '', + lastMessage: '', + actions: [], + hasOneAction: false, + hasTwoActions: false, + hasManyActions: false + }, + + /** + * @returns {undefined} + */ + initialize: function () { + // Add needed property for easier template rendering + if (this.get('actions').length === 0) { + this.set('hasOneAction', true); + } else if (this.get('actions').length === 1) { + this.set('hasTwoActions', true); + this.set('secondAction', this.get('actions')[0]); + } else { + this.set('hasManyActions', true); + } + } +}); + +/** + * @class ContactCollection + * @private + */ +const ContactCollection = Collection.extend({ + model: Contact +}); + +/** + * @class ContactsListView + * @private + */ +const ContactsListView = View.extend({ + + /** @type {ContactCollection} */ + _collection: undefined, + + /** @type {array} */ + _subViews: [], + + /** + * @param {object} options + * @returns {undefined} + */ + initialize: function (options) { + this._collection = options.collection; + }, + + /** + * @returns {self} + */ + render: function () { + var self = this; + self.$el.html(''); + self._subViews = []; + + self._collection.forEach(function (contact) { + var item = new ContactsListItemView({ + model: contact + }); + item.render(); + self.$el.append(item.$el); + item.on('toggle:actionmenu', self._onChildActionMenuToggle, self); + self._subViews.push(item); + }); + + return self; + }, + + /** + * Event callback to propagate opening (another) entry's action menu + * + * @param {type} $src + * @returns {undefined} + */ + _onChildActionMenuToggle: function ($src) { + this._subViews.forEach(function (view) { + view.trigger('parent:toggle:actionmenu', $src); + }); + } +}); + +/** + * @class ContactsListItemView + * @private + */ +const ContactsListItemView = View.extend({ + + /** @type {string} */ + className: 'contact', + + /** @type {undefined|function} */ + _template: undefined, + + /** @type {Contact} */ + _model: undefined, + + /** @type {boolean} */ + _actionMenuShown: false, + + events: { + 'click .icon-more': '_onToggleActionsMenu' + }, + + /** + * @param {object} data + * @returns {undefined} + */ + template: function (data) { + return OC.ContactsMenu.Templates['contact'](data); + }, + + /** + * @param {object} options + * @returns {undefined} + */ + initialize: function (options) { + this._model = options.model; + this.on('parent:toggle:actionmenu', this._onOtherActionMenuOpened, this); + }, + + /** + * @returns {self} + */ + render: function () { + this.$el.html(this.template({ + contact: this._model.toJSON() + })); + this.delegateEvents(); + + // Show placeholder if no avatar is available (avatar is rendered as img, not div) + this.$('div.avatar').imageplaceholder(this._model.get('fullName')); + + // Show tooltip for top action + this.$('.top-action').tooltip({placement: 'left'}); + // Show tooltip for second action + this.$('.second-action').tooltip({placement: 'left'}); + + return this; + }, + + /** + * Toggle the visibility of the action popover menu + * + * @private + * @returns {undefined} + */ + _onToggleActionsMenu: function () { + this._actionMenuShown = !this._actionMenuShown; + if (this._actionMenuShown) { + this.$('.menu').show(); + } else { + this.$('.menu').hide(); + } + this.trigger('toggle:actionmenu', this.$el); + }, + + /** + * @private + * @argument {jQuery} $src + * @returns {undefined} + */ + _onOtherActionMenuOpened: function ($src) { + if (this.$el.is($src)) { + // Ignore + return; + } + this._actionMenuShown = false; + this.$('.menu').hide(); + } +}); + +/** + * @class ContactsMenuView + * @private + */ +const ContactsMenuView = View.extend({ + + /** @type {undefined|function} */ + _loadingTemplate: undefined, + + /** @type {undefined|function} */ + _errorTemplate: undefined, + + /** @type {undefined|function} */ + _contentTemplate: undefined, + + /** @type {undefined|function} */ + _contactsTemplate: undefined, + + /** @type {undefined|ContactCollection} */ + _contacts: undefined, + + /** @type {string} */ + _searchTerm: '', + + events: { + 'input #contactsmenu-search': '_onSearch' + }, + + /** + * @returns {undefined} + */ + _onSearch: _.debounce(function (e) { + var searchTerm = this.$('#contactsmenu-search').val(); + // IE11 triggers an 'input' event after the view has been rendered + // resulting in an endless loading loop. To prevent this, we remember + // the last search term to savely ignore some events + // See https://github.com/nextcloud/server/issues/5281 + if (searchTerm !== this._searchTerm) { + this.trigger('search', this.$('#contactsmenu-search').val()); + this._searchTerm = searchTerm; + } + }, 700), + + /** + * @param {object} data + * @returns {string} + */ + loadingTemplate: function (data) { + return OC.ContactsMenu.Templates['loading'](data); + }, + + /** + * @param {object} data + * @returns {string} + */ + errorTemplate: function (data) { + return OC.ContactsMenu.Templates['error']( + _.extend({ + couldNotLoadText: t('core', 'Could not load your contacts') + }, data) + ); + }, + + /** + * @param {object} data + * @returns {string} + */ + contentTemplate: function (data) { + return OC.ContactsMenu.Templates['menu']( + _.extend({ + searchContactsText: t('core', 'Search contacts …') + }, data) + ); + }, + + /** + * @param {object} data + * @returns {string} + */ + contactsTemplate: function (data) { + return OC.ContactsMenu.Templates['list']( + _.extend({ + noContactsFoundText: t('core', 'No contacts found'), + showAllContactsText: t('core', 'Show all contacts …') + }, data) + ); + }, + + /** + * @param {object} options + * @returns {undefined} + */ + initialize: function (options) { + this.options = options; + }, + + /** + * @param {string} text + * @returns {undefined} + */ + showLoading: function (text) { + this.render(); + this._contacts = undefined; + this.$('.content').html(this.loadingTemplate({ + loadingText: text + })); + }, + + /** + * @returns {undefined} + */ + showError: function () { + this.render(); + this._contacts = undefined; + this.$('.content').html(this.errorTemplate()); + }, + + /** + * @param {object} viewData + * @param {string} searchTerm + * @returns {undefined} + */ + showContacts: function (viewData, searchTerm) { + this._contacts = viewData.contacts; + this.render({ + contacts: viewData.contacts + }); + + var list = new ContactsListView({ + collection: viewData.contacts + }); + list.render(); + this.$('.content').html(this.contactsTemplate({ + contacts: viewData.contacts, + searchTerm: searchTerm, + contactsAppEnabled: viewData.contactsAppEnabled, + contactsAppURL: OC.generateUrl('/apps/contacts') + })); + this.$('#contactsmenu-contacts').html(list.$el); + }, + + /** + * @param {object} data + * @returns {self} + */ + render: function (data) { + var searchVal = this.$('#contactsmenu-search').val(); + this.$el.html(this.contentTemplate(data)); + + // Focus search + this.$('#contactsmenu-search').val(searchVal); + this.$('#contactsmenu-search').focus(); + return this; + } + +}); + +/** + * @param {Object} options + * @param {jQuery} options.el + * @param {jQuery} options.trigger + * @class ContactsMenu + * @memberOf OC + */ +const ContactsMenu = function (options) { + this.initialize(options); +}; + +ContactsMenu.prototype = { + /** @type {jQuery} */ + $el: undefined, + + /** @type {jQuery} */ + _$trigger: undefined, + + /** @type {ContactsMenuView} */ + _view: undefined, + + /** @type {Promise} */ + _contactsPromise: undefined, + + /** + * @param {Object} options + * @param {jQuery} options.el - the element to render the menu in + * @param {jQuery} options.trigger - the element to click on to open the menu + * @returns {undefined} + */ + initialize: function (options) { + this.$el = options.el; + this._$trigger = options.trigger; + + this._view = new ContactsMenuView({ + el: this.$el + }); + this._view.on('search', function (searchTerm) { + this._loadContacts(searchTerm); + }, this); + + OC.registerMenu(this._$trigger, this.$el, function () { + this._toggleVisibility(true); + }.bind(this), true); + this.$el.on('beforeHide', function () { + this._toggleVisibility(false); + }.bind(this)); + }, + + /** + * @private + * @param {boolean} show + * @returns {Promise} + */ + _toggleVisibility: function (show) { + if (show) { + return this._loadContacts(); + } else { + this.$el.html(''); + return Promise.resolve(); + } + }, + + /** + * @private + * @param {string|undefined} searchTerm + * @returns {Promise} + */ + _getContacts: function (searchTerm) { + var url = OC.generateUrl('/contactsmenu/contacts'); + return Promise.resolve($.ajax(url, { + method: 'POST', + data: { + filter: searchTerm + } + })); + }, + + /** + * @param {string|undefined} searchTerm + * @returns {undefined} + */ + _loadContacts: function (searchTerm) { + var self = this; + + if (!self._contactsPromise) { + self._contactsPromise = self._getContacts(searchTerm); + } + + if (_.isUndefined(searchTerm) || searchTerm === '') { + self._view.showLoading(t('core', 'Loading your contacts …')); + } else { + self._view.showLoading(t('core', 'Looking for {term} …', { + term: searchTerm + })); + } + return self._contactsPromise.then(function (data) { + // Convert contact entries to Backbone collection + data.contacts = new ContactCollection(data.contacts); + + self._view.showContacts(data, searchTerm); + }, function (e) { + self._view.showError(); + console.error('There was an error loading your contacts', e); + }).then(function () { + // Delete promise, so that contacts are fetched again when the + // menu is opened the next time. + delete self._contactsPromise; + }).catch(console.error.bind(this)); + } +}; + +export default ContactsMenu; diff --git a/core/src/OC/index.js b/core/src/OC/index.js index 6ae1dc12c90..f7f5018253c 100644 --- a/core/src/OC/index.js +++ b/core/src/OC/index.js @@ -19,9 +19,21 @@ * along with this program. If not, see <http://www.gnu.org/licenses/>. */ +import Backbone from 'backbone'; + import Apps from './apps' +import ContactsMenu from './contactsmenu'; +import {davCall, davSync} from './backbone-webdav'; + +// Patch Backbone for DAV +Object.assign(Backbone, { + davCall, + davSync, +}); /** @namespace OC */ export default { Apps, + Backbone, + ContactsMenu, }; |