From 6a375ca161d945c1cbed6dc8485eb50861eb8073 Mon Sep 17 00:00:00 2001 From: Christoph Wurst Date: Mon, 2 Oct 2023 19:48:30 +0200 Subject: refactor: Contacts menu to Vue Signed-off-by: Christoph Wurst Signed-off-by: nextcloud-command --- core/src/OC/contactsmenu.js | 473 --------------------- core/src/OC/contactsmenu/contact.handlebars | 70 --- core/src/OC/contactsmenu/error.handlebars | 4 - core/src/OC/contactsmenu/list.handlebars | 12 - core/src/OC/contactsmenu/loading.handlebars | 4 - core/src/OC/contactsmenu/menu.handlebars | 4 - core/src/OC/index.js | 2 - core/src/components/ContactsMenu/Contact.vue | 191 +++++++++ .../tests/components/ContactsMenu/Contact.spec.js | 59 +++ core/src/tests/views/ContactsMenu.spec.js | 174 ++++++++ core/src/views/ContactsMenu.vue | 226 +++++----- 11 files changed, 537 insertions(+), 682 deletions(-) delete mode 100644 core/src/OC/contactsmenu.js delete mode 100644 core/src/OC/contactsmenu/contact.handlebars delete mode 100644 core/src/OC/contactsmenu/error.handlebars delete mode 100644 core/src/OC/contactsmenu/list.handlebars delete mode 100644 core/src/OC/contactsmenu/loading.handlebars delete mode 100644 core/src/OC/contactsmenu/menu.handlebars create mode 100644 core/src/components/ContactsMenu/Contact.vue create mode 100644 core/src/tests/components/ContactsMenu/Contact.spec.js create mode 100644 core/src/tests/views/ContactsMenu.spec.js (limited to 'core/src') 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 - * - * @author Christoph Wurst - * @author John Molakvoæ - * @author Roeland Jago Douma - * - * @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 . - * - */ - -/* 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}} - - {{contact.avatarLabel}} - - {{/if}} - {{else}} - {{contact.avatarLabel}} - {{/if}} -{{else}} - {{#if contact.profileUrl}} - {{#if contact.profileTitle}} - -
-
- {{/if}} - {{else}} -
- {{/if}} -{{/if}} -{{#if contact.profileUrl}} - {{#if contact.profileTitle}} - -
{{contact.fullName}}
-
{{contact.lastMessage}}
- -
- {{/if}} - {{#if contact.topAction}} - - {{contact.topAction.title}} - - {{/if}} -{{else if contact.topAction}} - -
{{contact.fullName}}
-
{{contact.lastMessage}}
- -
- - {{contact.topAction.title}} - -{{else}} -
-
{{contact.fullName}}
-
{{contact.lastMessage}}
- -
-{{/if}} -{{#if contact.hasTwoActions}} - - {{contact.secondAction.title}} - -{{/if}} -{{#if contact.hasManyActions}} - - -{{/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 @@ -
- -

{{couldNotLoadText}}

-
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}} -
- -

{{noContactsFoundText}}

-
-{{/unless}} -
-{{#if contactsAppEnabled}} - -{{else if canInstallApp}} - -{{/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 @@ -
-
-

{{loadingText}}

-
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 @@ - - -
-
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 @@ + + + + + + + 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 + * + * @author 2023 Christoph Wurst + * + * @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 . + */ + +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 + * + * @author 2023 Christoph Wurst + * + * @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 . + */ + +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 @@ -- cgit v1.2.3