diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2023-10-02 19:48:30 +0200 |
---|---|---|
committer | nextcloud-command <nextcloud-command@users.noreply.github.com> | 2023-10-16 14:12:20 +0000 |
commit | 6a375ca161d945c1cbed6dc8485eb50861eb8073 (patch) | |
tree | 490f79cde1714afd121975a3c37b9b8ebaebc73f /core | |
parent | c932c94fdd18a0799c522e4e25cca197724d9f2f (diff) | |
download | nextcloud-server-6a375ca161d945c1cbed6dc8485eb50861eb8073.tar.gz nextcloud-server-6a375ca161d945c1cbed6dc8485eb50861eb8073.zip |
refactor: Contacts menu to Vue
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
Diffstat (limited to 'core')
-rw-r--r-- | core/js/tests/specs/contactsmenuSpec.js | 265 | ||||
-rw-r--r-- | core/src/OC/contactsmenu.js | 473 | ||||
-rw-r--r-- | core/src/OC/contactsmenu/contact.handlebars | 70 | ||||
-rw-r--r-- | core/src/OC/contactsmenu/error.handlebars | 4 | ||||
-rw-r--r-- | core/src/OC/contactsmenu/list.handlebars | 12 | ||||
-rw-r--r-- | core/src/OC/contactsmenu/loading.handlebars | 4 | ||||
-rw-r--r-- | core/src/OC/contactsmenu/menu.handlebars | 4 | ||||
-rw-r--r-- | core/src/OC/index.js | 2 | ||||
-rw-r--r-- | core/src/components/ContactsMenu/Contact.vue | 191 | ||||
-rw-r--r-- | core/src/tests/components/ContactsMenu/Contact.spec.js | 59 | ||||
-rw-r--r-- | core/src/tests/views/ContactsMenu.spec.js | 174 | ||||
-rw-r--r-- | core/src/views/ContactsMenu.vue | 226 |
12 files changed, 537 insertions, 947 deletions
diff --git a/core/js/tests/specs/contactsmenuSpec.js b/core/js/tests/specs/contactsmenuSpec.js deleted file mode 100644 index eea1df1a7cd..00000000000 --- a/core/js/tests/specs/contactsmenuSpec.js +++ /dev/null @@ -1,265 +0,0 @@ -/* global expect, sinon, _, spyOn, Promise */ - -/** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @license AGPL-3.0-or-later - * - * 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/>. - * - */ - -describe('Contacts menu', function() { - var $triggerEl, - $menuEl, - menu; - - /** - * @private - * @returns {Promise} - */ - function openMenu() { - return menu.loadContacts(); - } - - beforeEach(function(done) { - $triggerEl = $('<div class="menutoggle">'); - $menuEl = $('<div class="menu">'); - - menu = new OC.ContactsMenu({ - el: $menuEl, - trigger: $triggerEl - }); - done(); - }); - - it('shows a loading message while data is being fetched', function() { - fakeServer.respondWith('GET', OC.generateUrl('/contactsmenu/contacts'), [ - 200, - {}, - '' - ]); - - openMenu(); - - expect($menuEl.html()).toContain('Loading your contacts …'); - }); - - it('shows an error message when loading the contacts data fails', function(done) { - spyOn(console, 'error'); - fakeServer.respondWith('GET', OC.generateUrl('/contactsmenu/contacts'), [ - 500, - {}, - '' - ]); - - var opening = openMenu(); - - expect($menuEl.html()).toContain('Loading your contacts …'); - fakeServer.respond(); - - opening.then(function() { - expect($menuEl.html()).toContain('Could not load your contacts'); - expect(console.error).toHaveBeenCalled(); - done(); - }, function(e) { - done.fail(e); - }); - }); - - it('loads data successfully', function(done) { - spyOn(menu, '_getContacts').and.returnValue(Promise.resolve({ - contacts: [ - { - id: null, - fullName: 'Acosta Lancaster', - topAction: { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:deboraoliver%40centrexin.com' - }, - actions: [ - { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:mathisholland%40virxo.com' - }, - { - title: 'Details', - icon: 'icon-info', - hyperlink: 'https:\/\/localhost\/index.php\/apps\/contacts' - } - ], - lastMessage: '' - }, - { - id: null, - fullName: 'Adeline Snider', - topAction: { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:ceciliasoto%40essensia.com' - }, - actions: [ - { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:pearliesellers%40inventure.com' - }, - { - title: 'Details', - icon: 'icon-info', - hyperlink: 'https://localhost\/index.php\/apps\/contacts' - } - ], - lastMessage: 'cu' - } - ], - contactsAppEnabled: true - })); - - openMenu().then(function() { - expect(menu._getContacts).toHaveBeenCalled(); - expect($menuEl.html()).toContain('Acosta Lancaster'); - expect($menuEl.html()).toContain('Adeline Snider'); - expect($menuEl.html()).toContain('Show all contacts …'); - done(); - }, function(e) { - done.fail(e); - }); - - }); - - it('doesn\'t show a link to the contacts app if it\'s disabled', function(done) { - spyOn(menu, '_getContacts').and.returnValue(Promise.resolve({ - contacts: [ - { - id: null, - fullName: 'Acosta Lancaster', - topAction: { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:deboraoliver%40centrexin.com' - }, - actions: [ - { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:mathisholland%40virxo.com' - }, - { - title: 'Details', - icon: 'icon-info', - hyperlink: 'https:\/\/localhost\/index.php\/apps\/contacts' - } - ], - lastMessage: '' - } - ], - contactsAppEnabled: false - })); - - openMenu().then(function() { - expect(menu._getContacts).toHaveBeenCalled(); - expect($menuEl.html()).not.toContain('Show all contacts …'); - done(); - }, function(e) { - done.fail(e); - }); - }); - - it('shows only one entry\'s action menu at a time', function(done) { - spyOn(menu, '_getContacts').and.returnValue(Promise.resolve({ - contacts: [ - { - id: null, - fullName: 'Acosta Lancaster', - topAction: { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:deboraoliver%40centrexin.com' - }, - actions: [ - { - title: 'Info', - icon: 'icon-info', - hyperlink: 'https:\/\/localhost\/index.php\/apps\/contacts' - }, - { - title: 'Details', - icon: 'icon-info', - hyperlink: 'https:\/\/localhost\/index.php\/apps\/contacts' - } - ], - lastMessage: '' - }, - { - id: null, - fullName: 'Adeline Snider', - topAction: { - title: 'Mail', - icon: 'icon-mail', - hyperlink: 'mailto:ceciliasoto%40essensia.com' - }, - actions: [ - { - title: 'Info', - icon: 'icon-info', - hyperlink: 'https://localhost\/index.php\/apps\/contacts' - }, - { - title: 'Details', - icon: 'icon-info', - hyperlink: 'https://localhost\/index.php\/apps\/contacts' - } - ], - lastMessage: 'cu' - } - ], - contactsAppEnabled: true - })); - - openMenu().then(function() { - expect(menu._getContacts).toHaveBeenCalled(); - expect($menuEl.html()).toContain('Adeline Snider'); - expect($menuEl.html()).toContain('Show all contacts …'); - - // Both menus are closed at the beginning - expect($menuEl.find('.contact').eq(0).find('.menu').is(':visible')).toBe(false); - expect($menuEl.find('.contact').eq(1).find('.menu').is(':visible')).toBe(false); - - // Open the first one - $menuEl.find('.contact').eq(0).find('.other-actions').click(); - expect($menuEl.find('.contact').eq(0).find('.menu').css('display')).toBe(''); - expect($menuEl.find('.contact').eq(1).find('.menu').css('display')).toBe('none'); - - // Open the second one - $menuEl.find('.contact').eq(1).find('.other-actions').click(); - expect($menuEl.find('.contact').eq(0).find('.menu').css('display')).toBe('none'); - expect($menuEl.find('.contact').eq(1).find('.menu').css('display')).toBe(''); - - // Close the second one - $menuEl.find('.contact').eq(1).find('.other-actions').click(); - expect($menuEl.find('.contact').eq(0).find('.menu').css('display')).toBe('none'); - expect($menuEl.find('.contact').eq(1).find('.menu').css('display')).toBe('none'); - - done(); - }, function(e) { - done.fail(e); - }); - }); - -}); diff --git a/core/src/OC/contactsmenu.js b/core/src/OC/contactsmenu.js deleted file mode 100644 index 61fe3b49506..00000000000 --- a/core/src/OC/contactsmenu.js +++ /dev/null @@ -1,473 +0,0 @@ -/** - * @copyright 2017 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0-or-later - * - * 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/>. - * - */ - -/* eslint-disable */ -import _ from 'underscore' -import $ from 'jquery' -import { Collection, Model, View } from 'backbone' - -import OC from './index.js' - -/** - * @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) - } - - const fullName = this.get('fullName') - if (this.get('avatar') && fullName) { - this.set('avatarLabel', t('core', 'Avatar of {fullName}', { fullName })) - } - } -}) - -/** - * @class ContactCollection - * @private - */ -const ContactCollection = Collection.extend({ - model: Contact -}) - -/** - * @class ContactsListView - * @private - */ -const ContactsListView = View.extend({ - - /** @type {ContactCollection} */ - _collection: undefined, - - /** @type {array} */ - _subViews: [], - - /** @type {string} */ - tagName: 'ul', - - /** - * @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 {string} */ - tagName: 'li', - - /** @type {undefined|function} */ - _template: undefined, - - /** @type {Contact} */ - _model: undefined, - - /** @type {boolean} */ - _actionMenuShown: false, - - events: { - 'click .icon-more': '_onToggleActionsMenu' - }, - - contactTemplate: require('./contactsmenu/contact.handlebars'), - - /** - * @param {object} data - * @returns {undefined} - */ - template: function(data) { - return this.contactTemplate(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')) - - 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' - }, - - templates: { - loading: require('./contactsmenu/loading.handlebars'), - error: require('./contactsmenu/error.handlebars'), - menu: require('./contactsmenu/menu.handlebars'), - list: require('./contactsmenu/list.handlebars') - }, - - /** - * @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 this.templates.loading(data) - }, - - /** - * @param {object} data - * @returns {string} - */ - errorTemplate: function(data) { - return this.templates.error( - _.extend({ - couldNotLoadText: t('core', 'Could not load your contacts') - }, data) - ) - }, - - /** - * @param {object} data - * @returns {string} - */ - contentTemplate: function(data) { - return this.templates.menu( - _.extend({ - searchContactsLabel: t('core', 'Search contacts'), - }, data) - ) - }, - - /** - * @param {object} data - * @returns {string} - */ - contactsTemplate: function(data) { - return this.templates.list( - _.extend({ - noContactsFoundText: t('core', 'No contacts found'), - showAllContactsText: t('core', 'Show all contacts …'), - contactsAppMgmtText: t('core', 'Install the Contacts app') - }, 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'), - canInstallApp: OC.isUserAdmin(), - contactsAppMgmtURL: OC.generateUrl('/settings/apps/social/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 {string} options.el - * @class ContactsMenu - * @memberOf OC - */ -const ContactsMenu = function(options) { - this.initialize(options) -} - -ContactsMenu.prototype = { - /** @type {string} */ - $el: undefined, - - /** @type {ContactsMenuView} */ - _view: undefined, - - /** @type {Promise} */ - _contactsPromise: undefined, - - /** - * @param {Object} options - * @param {string} options.el - the selector of the element to render the menu in - * @returns {undefined} - */ - initialize: function(options) { - this.$el = $(options.el) - - this._view = new ContactsMenuView({ - el: this.$el, - }) - - this._view.on('search', function(searchTerm) { - this.loadContacts(searchTerm) - }, this) - }, - - /** - * @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/contactsmenu/contact.handlebars b/core/src/OC/contactsmenu/contact.handlebars deleted file mode 100644 index c020cb797da..00000000000 --- a/core/src/OC/contactsmenu/contact.handlebars +++ /dev/null @@ -1,70 +0,0 @@ -{{#if contact.avatar}} - {{#if contact.profileUrl}} - {{#if contact.profileTitle}} - <a class="profile-link--avatar" href="{{contact.profileUrl}}"> - <img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="{{contact.avatarLabel}}"> - </a> - {{/if}} - {{else}} - <img src="{{contact.avatar}}&size=32" class="avatar" srcset="{{contact.avatar}}&size=32 1x, {{contact.avatar}}&size=64 2x, {{contact.avatar}}&size=128 4x" alt="{{contact.avatarLabel}}"> - {{/if}} -{{else}} - {{#if contact.profileUrl}} - {{#if contact.profileTitle}} - <a class="profile-link--avatar" href="{{contact.profileUrl}}"> - <div class="avatar"></div> - </a> - {{/if}} - {{else}} - <div class="avatar"></div> - {{/if}} -{{/if}} -{{#if contact.profileUrl}} - {{#if contact.profileTitle}} - <a class="body profile-link--full-name" href="{{contact.profileUrl}}"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> - <div class="email-address">{{contact.emailAddresses}}</div> - </a> - {{/if}} - {{#if contact.topAction}} - <a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}" aria-label="{{contact.topAction.title}}"> - <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}"> - </a> - {{/if}} -{{else if contact.topAction}} - <a class="body" href="{{contact.topAction.hyperlink}}"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> - <div class="email-address">{{contact.emailAddresses}}</div> - </a> - <a class="top-action" href="{{contact.topAction.hyperlink}}" title="{{contact.topAction.title}}"> - <img src="{{contact.topAction.icon}}" alt="{{contact.topAction.title}}"> - </a> -{{else}} - <div class="body"> - <div class="full-name">{{contact.fullName}}</div> - <div class="last-message">{{contact.lastMessage}}</div> - <div class="email-address">{{contact.emailAddresses}}</div> - </div> -{{/if}} -{{#if contact.hasTwoActions}} -<a class="second-action" href="{{contact.secondAction.hyperlink}}" aria-label="{{contact.secondAction.title}}" title="{{contact.secondAction.title}}"> - <img src="{{contact.secondAction.icon}}" alt="{{contact.secondAction.title}}"> -</a> -{{/if}} -{{#if contact.hasManyActions}} - <button class="other-actions icon-more"></button> - <div class="menu popovermenu"> - <ul> - {{#each contact.actions}} - <li> - <a href="{{hyperlink}}"> - <img src="{{icon}}" alt=""> - <span>{{title}}</span> - </a> - </li> - {{/each}} - </ul> - </div> -{{/if}} diff --git a/core/src/OC/contactsmenu/error.handlebars b/core/src/OC/contactsmenu/error.handlebars deleted file mode 100644 index 5115595b4e1..00000000000 --- a/core/src/OC/contactsmenu/error.handlebars +++ /dev/null @@ -1,4 +0,0 @@ -<div class="emptycontent"> - <div class="icon-search"></div> - <h2>{{couldNotLoadText}}</h2> -</div> diff --git a/core/src/OC/contactsmenu/list.handlebars b/core/src/OC/contactsmenu/list.handlebars deleted file mode 100644 index 0bcff7d1a85..00000000000 --- a/core/src/OC/contactsmenu/list.handlebars +++ /dev/null @@ -1,12 +0,0 @@ -{{#unless contacts.length}} -<div class="emptycontent"> - <div class="icon-search"></div> - <h2>{{noContactsFoundText}}</h2> -</div> -{{/unless}} -<div id="contactsmenu-contacts"></div> -{{#if contactsAppEnabled}} -<div class="footer"><a href="{{contactsAppURL}}">{{showAllContactsText}}</a></div> -{{else if canInstallApp}} -<div class="footer"><a href="{{contactsAppMgmtURL}}">{{contactsAppMgmtText}}</a></div> -{{/if}} diff --git a/core/src/OC/contactsmenu/loading.handlebars b/core/src/OC/contactsmenu/loading.handlebars deleted file mode 100644 index 7fb22a6ed8e..00000000000 --- a/core/src/OC/contactsmenu/loading.handlebars +++ /dev/null @@ -1,4 +0,0 @@ -<div class="emptycontent"> - <div class="icon-loading"></div> - <h2>{{loadingText}}</h2> -</div> diff --git a/core/src/OC/contactsmenu/menu.handlebars b/core/src/OC/contactsmenu/menu.handlebars deleted file mode 100644 index 89135c23d37..00000000000 --- a/core/src/OC/contactsmenu/menu.handlebars +++ /dev/null @@ -1,4 +0,0 @@ -<label for="contactsmenu-search">{{searchContactsLabel}}</label> -<input id="contactsmenu-search" type="search" value="{{searchTerm}}"> -<div class="content"> -</div> diff --git a/core/src/OC/index.js b/core/src/OC/index.js index becaabe6e21..07064fba98e 100644 --- a/core/src/OC/index.js +++ b/core/src/OC/index.js @@ -57,7 +57,6 @@ import { PERMISSION_UPDATE, TAG_FAVORITE, } from './constants.js' -import ContactsMenu from './contactsmenu.js' import { currentUser, getCurrentUser } from './currentuser.js' import Dialogs from './dialogs.js' import EventSource from './eventsource.js' @@ -141,7 +140,6 @@ export default { appConfig, appswebroots, Backbone, - ContactsMenu, config: Config, /** * Currently logged in user or null if none diff --git a/core/src/components/ContactsMenu/Contact.vue b/core/src/components/ContactsMenu/Contact.vue new file mode 100644 index 00000000000..bf2447e5889 --- /dev/null +++ b/core/src/components/ContactsMenu/Contact.vue @@ -0,0 +1,191 @@ +<!-- + - @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2023 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/>. + --> + +<template> + <li class="contact"> + <a v-if="contact.profileUrl && contact.avatar" + :href="contact.profileUrl" + class="contact__avatar-wrapper"> + <NcAvatar class="contact__avatar" + :is-no-user="true" + :display-name="contact.avatarLabel" + :url="contact.avatar" /> + </a> + <a v-else-if="contact.profileUrl" + :href="contact.profileUrl"> + <NcAvatar class="contact__avatar" + :is-no-user="true" + :display-name="contact.avatarLabel" /> + </a> + <NcAvatar v-else + class="contact__avatar" + :is-no-user="true" + :display-name="contact.avatarLabel" + :url="contact.avatar" /> + + <a class="contact__body" + :href="contact.profileUrl || contact.topAction?.hyperlink"> + <div class="contact__body__full-name">{{ contact.fullName }}</div> + <div v-if="contact.lastMessage" class="contact__body__last-message">{{ contact.lastMessage }}</div> + <div class="contact__body__email-address">{{ contact.emailAddresses[0] }}</div> + </a> + <NcActions v-if="actions.length" + :inline="contact.topAction ? 1 : 0"> + <template v-for="(action, idx) in actions"> + <NcActionLink v-if="action.hyperlink !== '#'" + :key="idx" + :href="action.hyperlink" + class="other-actions"> + <template #icon> + <img class="contact__action__icon" :src="action.icon"> + </template> + {{ action.title }} + </NcActionLink> + <NcActionText v-else :key="idx" class="other-actions"> + <template #icon> + <img class="contact__action__icon" :src="action.icon"> + </template> + {{ action.title }} + </NcActionText> + </template> + </NcActions> + </li> +</template> + +<script> +import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js' +import NcActionText from '@nextcloud/vue/dist/Components/NcActionText.js' +import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' +import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' + +export default { + name: 'Contact', + components: { + NcActionLink, + NcActionText, + NcActions, + NcAvatar, + }, + props: { + contact: { + required: true, + type: Object, + }, + }, + computed: { + actions() { + if (this.contact.topAction) { + return [this.contact.topAction, ...this.contact.actions] + } + return this.contact.actions + }, + }, +} +</script> + +<style scoped lang="scss"> +.contact { + display: flex; + position: relative; + align-items: center; + padding: 3px 3px 3px 10px; + + &__action { + &__icon { + width: 20px; + height: 20px; + padding: 12px; + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + } + + &__avatar-wrapper { + height: 32px; + } + + &__avatar { + height: 32px; + width: 32px; + display: inherit; + } + + &__body { + flex-grow: 1; + padding-left: 8px; + min-width: 0; + + div { + position: relative; + width: 100%; + overflow-x: hidden; + text-overflow: ellipsis; + } + + .last-message, .email-address { + color: var(--color-text-maxcontrast); + } + } + + .other-actions { + width: 16px; + height: 16px; + opacity: .5; + cursor: pointer; + + img { + filter: var(--background-invert-if-dark); + } + + &:hover, + &:active, + &:focus { + opacity: 1; + } + } + + button.other-actions { + width: 44px; + + &:focus { + border-color: transparent; + box-shadow: 0 0 0 2px var(--color-main-text); + } + + &:focus-visible { + border-radius: var(--border-radius-pill); + } + } + + /* actions menu */ + .menu { + top: 47px; + margin-right: 13px; + } + + .popovermenu::after { + right: 2px; + } +} +</style> diff --git a/core/src/tests/components/ContactsMenu/Contact.spec.js b/core/src/tests/components/ContactsMenu/Contact.spec.js new file mode 100644 index 00000000000..bdf0238e5f9 --- /dev/null +++ b/core/src/tests/components/ContactsMenu/Contact.spec.js @@ -0,0 +1,59 @@ +/** + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 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 { shallowMount } from '@vue/test-utils' + +import Contact from '../../../components/ContactsMenu/Contact.vue' + +describe('Contact', function() { + it('links to the top action', () => { + const view = shallowMount(Contact, { + propsData: { + contact: { + id: null, + fullName: 'Acosta Lancaster', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:deboraoliver%40centrexin.com' + }, + emailAddresses: [], + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:mathisholland%40virxo.com' + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts' + }, + ], + lastMessage: '', + }, + }, + }) + + expect(view.find('li a').exists()).toBe(true) + expect(view.find('li a').attributes('href')).toBe('mailto:deboraoliver%40centrexin.com') + }) +}) diff --git a/core/src/tests/views/ContactsMenu.spec.js b/core/src/tests/views/ContactsMenu.spec.js new file mode 100644 index 00000000000..66c0532322b --- /dev/null +++ b/core/src/tests/views/ContactsMenu.spec.js @@ -0,0 +1,174 @@ +/** + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 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 axios from '@nextcloud/axios' +import { mount, shallowMount } from '@vue/test-utils' + +import ContactsMenu from '../../views/ContactsMenu.vue' + +jest.mock('@nextcloud/axios', () => ({ + post: jest.fn(), +})) + +describe('ContactsMenu', function() { + it('is closed by default', () => { + const view = shallowMount(ContactsMenu) + + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe(undefined) + }) + + it('shows a loading text', async () => { + const view = shallowMount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [], + contactsAppEnabled: false, + }, + }) + + const opening = view.vm.handleOpen() + + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe('Loading your contacts …') + await opening + }) + + it('shows error view when contacts can not be loaded', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValue({}) + jest.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await view.vm.handleOpen() + + throw new Error('should not be reached') + } catch (error) { + expect(console.error).toHaveBeenCalled() + console.error.mockRestore() + expect(view.vm.error).toBe(true) + expect(view.vm.contacts).toEqual([]) + expect(view.text()).toContain('Could not load your contacts') + } + }) + + it('shows text when there are no contacts', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [], + contactsAppEnabled: false, + }, + }) + + await view.vm.handleOpen() + + expect(view.vm.error).toBe(false) + expect(view.vm.contacts).toEqual([]) + expect(view.vm.loadingText).toBe(undefined) + expect(view.text()).toContain('No contacts found') + }) + + it('shows contacts', async () => { + const view = mount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [ + { + id: null, + fullName: 'Acosta Lancaster', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:deboraoliver%40centrexin.com' + }, + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:mathisholland%40virxo.com' + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts' + } + ], + lastMessage: '', + emailAddresses: [], + }, + { + id: null, + fullName: 'Adeline Snider', + topAction: { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:ceciliasoto%40essensia.com' + }, + actions: [ + { + title: 'Mail', + icon: 'icon-mail', + hyperlink: 'mailto:pearliesellers%40inventure.com' + }, + { + title: 'Details', + icon: 'icon-info', + hyperlink: 'https://localhost/index.php/apps/contacts' + } + ], + lastMessage: 'cu', + emailAddresses: [], + } + ], + contactsAppEnabled: false, + }, + }) + + await view.vm.handleOpen() + + expect(view.vm.error).toBe(false) + expect(view.vm.contacts.length).toBe(2) + expect(view.text()).toContain('Acosta Lancaster') + expect(view.text()).toContain('Adeline Snider') + }) + + it('shows link ot Contacts', async () => { + const view = shallowMount(ContactsMenu) + axios.post.mockResolvedValue({ + data: { + contacts: [ + { + id: 1, + }, + { + id: 2, + }, + ], + contactsAppEnabled: true, + }, + }) + + await view.vm.handleOpen() + + expect(view.text()).toContain('Show all contacts …') + }) +}) diff --git a/core/src/views/ContactsMenu.vue b/core/src/views/ContactsMenu.vue index 03ea5a08e79..9400cb83ca0 100644 --- a/core/src/views/ContactsMenu.vue +++ b/core/src/views/ContactsMenu.vue @@ -22,89 +22,163 @@ <template> <NcHeaderMenu id="contactsmenu" + class="contactsmenu" :aria-label="t('core', 'Search contacts')" @open="handleOpen"> <template #trigger> <Contacts :size="20" /> </template> - <div id="contactsmenu-menu" /> + <div class="contactsmenu__menu"> + <label for="contactsmenu__menu__search">{{ t('core', 'Search contacts') }}</label> + <input id="contactsmenu__menu__search" + v-model="searchTerm" + class="contactsmenu__menu__search" + type="search" + :placeholder="t('core', 'Search contacts …')" + @input="onInputDebounced"> + <NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')"> + <template #icon> + <Magnify /> + </template> + </NcEmptyContent> + <NcEmptyContent v-else-if="loadingText" :name="loadingText"> + <template #icon> + <NcLoadingIcon /> + </template> + </NcEmptyContent> + <NcEmptyContent v-else-if="contacts.length === 0" :name="t('core', 'No contacts found')"> + <template #icon> + <Magnify /> + </template> + </NcEmptyContent> + <div v-else class="contactsmenu__menu__content"> + <div id="contactsmenu-contacts"> + <ul> + <Contact v-for="contact in contacts" :key="contact.id" :contact="contact" /> + </ul> + </div> + <div v-if="contactsAppEnabled" class="contactsmenu__menu__content__footer"> + <a :href="contactsAppURL">{{ t('core', 'Show all contacts …') }}</a> + </div> + <div v-else-if="canInstallApp" class="contactsmenu__menu__content__footer"> + <a :href="contactsAppMgmtURL">{{ t('core', 'Install the Contacts app') }}</a> + </div> + </div> + </div> </NcHeaderMenu> </template> <script> -import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' - +import axios from '@nextcloud/axios' import Contacts from 'vue-material-design-icons/Contacts.vue' +import debounce from 'debounce' +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl } from '@nextcloud/router' +import Magnify from 'vue-material-design-icons/Magnify.vue' +import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js' +import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js' +import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js' +import { translate as t } from '@nextcloud/l10n' -import OC from '../OC/index.js' +import Contact from '../components/ContactsMenu/Contact.vue' +import logger from '../logger.js' +import Nextcloud from '../mixins/Nextcloud.js' export default { name: 'ContactsMenu', components: { + Contact, Contacts, + Magnify, + NcEmptyContent, NcHeaderMenu, + NcLoadingIcon, }, + mixins: [Nextcloud], + data() { + const user = getCurrentUser() return { - contactsMenu: null, + contactsAppEnabled: false, + contactsAppURL: generateUrl('/apps/contacts'), + contactsAppMgmtURL: generateUrl('/settings/apps/social/contacts'), + canInstallApp: user.isAdmin, + contacts: [], + loadingText: undefined, + error: false, + searchTerm: '', } }, - mounted() { - // eslint-disable-next-line no-new - this.contactsMenu = new OC.ContactsMenu({ - el: '#contactsmenu-menu', - }) - }, - methods: { - handleOpen() { - this.contactsMenu?.loadContacts() + async handleOpen() { + await this.getContacts('') }, + async getContacts(searchTerm) { + if (searchTerm === '') { + this.loadingText = t('core', 'Loading your contacts …') + } else { + this.loadingText = t('core', 'Looking for {term} …', { + term: searchTerm, + }) + } + + // Let the user try a different query if the previous one failed + this.error = false + + try { + const { data: { contacts, contactsAppEnabled } } = await axios.post(generateUrl('/contactsmenu/contacts'), { + filter: searchTerm, + }) + this.contacts = contacts + this.contactsAppEnabled = contactsAppEnabled + this.loadingText = undefined + } catch (error) { + logger.error('could not load contacts', { + error, + searchTerm, + }) + this.error = true + } + }, + onInputDebounced: debounce(function() { + this.getContacts(this.searchTerm) + }, 500), }, } </script> <style lang="scss" scoped> -#contactsmenu-menu { - /* show 2.5 to 4.5 entries depending on the screen height */ - height: calc(100vh - 50px * 3); - max-height: calc(50px * 6 + 2px + 26px); - min-height: calc(50px * 3.5); - width: 350px; - - &:deep { - .emptycontent { - margin-top: 5vh !important; - margin-bottom: 1.5vh; - .icon-loading, - .icon-search { - display: inline-block; - } - } - - label[for="contactsmenu-search"] { +.contactsmenu { + &__menu { + /* show 2.5 to 4.5 entries depending on the screen height */ + height: calc(100vh - 50px * 3); + max-height: calc(50px * 6 + 2px + 26px); + min-height: calc(50px * 3.5); + width: 350px; + + label[for="contactsmenu__menu__search"] { font-weight: bold; font-size: 19px; - margin-left: 22px; + margin-left: 13px; } - #contactsmenu-search { - width: calc(100% - 16px); - margin: 8px; + &__search { + width: 100%; height: 34px; + margin: 8px 0; } - .content { + &__content { /* fixed max height of the parent container without the search input */ - height: calc(100vh - 50px * 3 - 50px); + height: calc(100vh - 50px * 3 - 60px); max-height: calc(50px * 5); min-height: calc(50px * 3.5 - 50px); overflow-y: auto; - .footer { + &__footer { text-align: center; a { @@ -117,84 +191,10 @@ export default { } a { - padding: 2px; - &:focus-visible { box-shadow: inset 0 0 0 2px var(--color-main-text) !important; // override rule in core/css/headers.scss #header a:focus-visible } } - - .contact { - display: flex; - position: relative; - align-items: center; - padding: 3px 3px 3px 10px; - - .avatar { - height: 32px; - width: 32px; - display: inline-block; - } - - .body { - flex-grow: 1; - padding-left: 8px; - min-width: 0; - - div { - position: relative; - width: 100%; - overflow-x: hidden; - text-overflow: ellipsis; - } - - .last-message, .email-address { - color: var(--color-text-maxcontrast); - } - } - - .top-action, .second-action, .other-actions { - width: 16px; - height: 16px; - opacity: .5; - cursor: pointer; - - &:not(button) { - padding: 14px; - } - img { - filter: var(--background-invert-if-dark); - } - - &:hover, - &:active, - &:focus { - opacity: 1; - } - } - - button.other-actions { - width: 44px; - - &:focus { - border-color: transparent; - box-shadow: 0 0 0 2px var(--color-main-text); - } - - &:focus-visible { - border-radius: var(--border-radius-pill); - } - } - - /* actions menu */ - .menu { - top: 47px; - margin-right: 13px; - } - .popovermenu::after { - right: 2px; - } - } } } </style> |