summaryrefslogtreecommitdiffstats
path: root/core
diff options
context:
space:
mode:
authorChristoph Wurst <christoph@winzerhof-wurst.at>2023-10-02 19:48:30 +0200
committernextcloud-command <nextcloud-command@users.noreply.github.com>2023-10-16 14:12:20 +0000
commit6a375ca161d945c1cbed6dc8485eb50861eb8073 (patch)
tree490f79cde1714afd121975a3c37b9b8ebaebc73f /core
parentc932c94fdd18a0799c522e4e25cca197724d9f2f (diff)
downloadnextcloud-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.js265
-rw-r--r--core/src/OC/contactsmenu.js473
-rw-r--r--core/src/OC/contactsmenu/contact.handlebars70
-rw-r--r--core/src/OC/contactsmenu/error.handlebars4
-rw-r--r--core/src/OC/contactsmenu/list.handlebars12
-rw-r--r--core/src/OC/contactsmenu/loading.handlebars4
-rw-r--r--core/src/OC/contactsmenu/menu.handlebars4
-rw-r--r--core/src/OC/index.js2
-rw-r--r--core/src/components/ContactsMenu/Contact.vue191
-rw-r--r--core/src/tests/components/ContactsMenu/Contact.spec.js59
-rw-r--r--core/src/tests/views/ContactsMenu.spec.js174
-rw-r--r--core/src/views/ContactsMenu.vue226
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>